Merge pull request #488 from pikasTech/fix/code-agent-partial-result-tests
fix: harden Code Agent result lifecycle
This commit is contained in:
@@ -150,6 +150,19 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对
|
||||
可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期,
|
||||
刷新页面不得要求用户重新登录。
|
||||
|
||||
恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/<traceId>` 做
|
||||
终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成
|
||||
`source` 后停留在“处理中”文本;如果后端 result 已经完成,消息卡片必须替换为真实
|
||||
reply/blocker/session/providerTrace。如果 trace 只显示 `result:*` 终态事件而消息仍未完成,
|
||||
也必须立即查 result,而不是把 `Code Agent result is ready for short-connection polling.` 当成
|
||||
最终回复或让用户手工重试。
|
||||
|
||||
Cloud Workbench 的 Code Agent 单元测试不得依赖真实 token、真实公网 provider 或真实硬件。
|
||||
前端状态机测试应使用固定 fixture/mock fetch 覆盖 submit accepted、result completed、result
|
||||
failed、session busy、partial timeout、trace compacted、SSE 断流和页面恢复;后端测试应使用
|
||||
fake Codex app-server client 覆盖长耗时和缺失 `turn/completed`。真实 DEV `--live` smoke 只用于
|
||||
发布后读写边界明确的验收,不作为 PR 默认回归测试。
|
||||
|
||||
## Lightweight Checks
|
||||
|
||||
Use the existing lightweight checks unless a task explicitly authorizes a
|
||||
|
||||
@@ -208,12 +208,18 @@ Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示
|
||||
|
||||
Workbench 与 Code Agent 的用户请求必须是短连接 submit + 短连接 result/trace 轮询;浏览器或 cloud-web 不应持有一次长 HTTP 请求等待整个 Codex turn 结束。`POST /v1/agent/chat` 返回 `202` 后,前端轮询 `/v1/agent/chat/result/<traceId>` 获取终态,并用 `/v1/agent/chat/trace/<traceId>` 刷新可视 trace。
|
||||
|
||||
Code Agent backend 的 completed 语义只能来自真实 Codex app-server `turn/completed` 成功事件。`item/agentMessage/delta`、`item/completed`、已有 assistant 文本、transport close 或 activity idle timeout 都不能单独升级成 `status: "completed"`。如果已经收到部分 assistant 文本但没有收到 `turn/completed`,终态必须是 timeout/partial blocker,并保留 trace、session、thread、partial output 摘要和可重试提示;Workbench 只能显示“部分回复/超时”,不能标 DEV-LIVE reply pass。
|
||||
|
||||
cloud-web 同源代理必须把短连接语义原样转发给 cloud-api,至少包括 `Prefer: respond-async`、`X-HWLAB-Short-Connection` 和 `X-Trace-Id`。如果这些 header 在 cloud-web 层被过滤,cloud-api 会把同一个请求当成长同步请求处理,用户入口会表现为 `17666` 卡住或代理超时,而 `17667` 直连 cloud-api 正常。此类问题应先比对同一 trace 在 `17666` 与 `17667` 的 submit 行为,再修代理 header 透传,而不是调大前端等待时间。
|
||||
|
||||
`/v1/agent/chat/result/<traceId>` 是终态摘要接口,不是完整 trace 下载接口。它可以携带压缩后的 `runnerTrace` 窗口用于传输保活,但 Workbench 用户界面不得把该窗口显示为“压缩窗口”或“显示全部”。只要结果或轮询快照声明 `eventsCompacted=true`,前端必须自动请求 `/v1/agent/chat/trace/<traceId>` 并用完整 trace 替换可视事件线;回放完成前只能显示“完整 trace 回放中/当前已载入”状态。result 响应仍必须保留 `eventCount`、`lastEvent`、`providerTrace`、`threadId/sessionId` 和终态 reply/blocker;完整 trace 只能从 `/v1/agent/chat/trace/<traceId>`、复制 JSON 或下载 trace 入口取得。默认 result trace 窗口上限由 `HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT` 控制;不要把数百个大 chunk 原样塞进 result 响应,避免 cloud-web 代理层或浏览器 fetch 把“正常执行中的大响应”表现成 503、非 JSON 或空响应。
|
||||
|
||||
result 轮询的 408/425/429/5xx、浏览器 timeout、非 JSON 或空响应应按“可恢复传输抖动”处理:前端先拉取一次 trace 刷新活性,再带退避继续轮询,只有后端返回结构化 terminal blocker、真实终态失败,或 trace 按无新事件 idle timeout 超时,才向用户显示失败。只要 `/trace` 仍显示新事件或 `waitingFor` 仍在推进,就不能把一次 result poll 失败标成“Code Agent API 错误”并停止。
|
||||
|
||||
Workbench 的 trace 展示不是终态真相。只要 trace 中出现 `result:completed`、`result:failed`、`result:canceled`、`session:session_busy` 这类 terminal event,或浏览器从本地状态恢复出带 `traceId` 的非终态消息,前端必须再请求 `/v1/agent/chat/result/<traceId>` 做 result reconciliation,并用 result 的 reply/blocker/session/providerTrace 替换消息卡片。仅回放 `/trace` 不能把旧 running 消息改成完成,也不能把 `Code Agent result is ready for short-connection polling.` 当作用户可见最终回复。
|
||||
|
||||
长耗时和前后端协同缺陷必须优先用 mock/fixture 复现,不得把真实 token 调用作为日常回归测试。后端单元测试使用 fake Codex app-server JSON-RPC client 模拟 `thread/started`、`turn/started`、assistant delta、command output、`turn/completed` 缺失、transport close 和 idle timeout;前端测试使用固定 `/result`、`/trace`、EventSource、localStorage fixture 验证 result reconciliation、刷新恢复、transient poll error 和 compact trace 回放。真实 `--live` Code Agent smoke 只作为显式授权的 DEV 验证,不进入默认 PR 单元测试路径。
|
||||
|
||||
Windows 侧 skill、编译器、脚本工具和多参数命令都应走同一个通用传输模式,不新增某个工具的专用 wrapper 子命令:
|
||||
|
||||
- 工作目录优先用 wrapper 的 `--cwd <windows path>` 或脚本内 `Set-Location -LiteralPath <path>` 表达,不要在 prompt 里拼 `cmd /c "cd ... && ..."`。
|
||||
|
||||
@@ -1115,6 +1115,40 @@ function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eve
|
||||
};
|
||||
}
|
||||
|
||||
function createPartialAssistantNoTurnCompletedClient({ calls, text = "partial assistant output" } = {}) {
|
||||
let notificationHandler = null;
|
||||
let turn = 0;
|
||||
return {
|
||||
async initialize() {
|
||||
calls?.push({ method: "initialize" });
|
||||
return { initialized: true };
|
||||
},
|
||||
setNotificationHandler(handler) {
|
||||
notificationHandler = typeof handler === "function" ? handler : null;
|
||||
},
|
||||
async startThread(args) {
|
||||
calls?.push({ method: "thread/start", args });
|
||||
notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_stdio_partial" } } });
|
||||
return { threadId: "thread_stdio_partial" };
|
||||
},
|
||||
async resumeThread(args) {
|
||||
calls?.push({ method: "thread/resume", args });
|
||||
notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } });
|
||||
return { threadId: args.threadId };
|
||||
},
|
||||
async startTurn(args) {
|
||||
calls?.push({ method: "turn/start", args });
|
||||
turn += 1;
|
||||
const turnId = `turn_stdio_partial_${turn}`;
|
||||
notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } });
|
||||
notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } });
|
||||
notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } });
|
||||
return { turnId };
|
||||
},
|
||||
close() {}
|
||||
};
|
||||
}
|
||||
|
||||
test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => {
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_stdio_blocked"
|
||||
@@ -1628,6 +1662,63 @@ test("Codex app-server activity timeout fails only after no new events", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex app-server partial assistant output without turn completion is a timeout, not completed", async () => {
|
||||
const calls = [];
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const codexHome = await prepareFakeCodexHome();
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_stdio_partial_timeout",
|
||||
createRpcClient: async () => createPartialAssistantNoTurnCompletedClient({
|
||||
calls,
|
||||
text: "partial text that must not be trusted as completed"
|
||||
})
|
||||
});
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_stdio_partial_timeout",
|
||||
traceId: "trc_stdio_partial_timeout",
|
||||
message: "输出半截后不发 turn/completed"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-24T00:09:45.000Z",
|
||||
codexStdioManager: manager,
|
||||
env,
|
||||
timeoutMs: 30,
|
||||
hardTimeoutMs: 500
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "timeout");
|
||||
assert.equal(payload.error.code, "codex_stdio_idle_timeout");
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
assert.equal(payload.session.status, "timeout");
|
||||
assert.equal(payload.providerTrace, undefined);
|
||||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "assistant:item_completed"));
|
||||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "timeout:no_activity"));
|
||||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "turn:completed:idle_timeout_after_assistant"), false);
|
||||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "assistant:completed"), false);
|
||||
assert.deepEqual(calls.filter((call) => call.method !== "initialize").map((call) => call.method), ["thread/start", "turn/start"]);
|
||||
} finally {
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex app-server failed turn is not wrapped as completed assistant output", async () => {
|
||||
const calls = [];
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
|
||||
@@ -1803,34 +1803,14 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
|
||||
const now = Date.now();
|
||||
const activity = activitySnapshot(now);
|
||||
if (activity.idleMs >= timeoutMs) {
|
||||
if (hasAssistantOutput()) {
|
||||
appendTrace({
|
||||
type: "turn",
|
||||
status: "completed",
|
||||
label: "turn:completed:idle_timeout_after_assistant",
|
||||
message: "Codex app-server became idle after assistant output; preserving the completed assistant response.",
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "assistant-message-complete"
|
||||
});
|
||||
finish("completed", null, {
|
||||
threadId,
|
||||
turnId,
|
||||
idleTimeoutAfterAssistant: true,
|
||||
...activity
|
||||
});
|
||||
return;
|
||||
}
|
||||
const error = appServerActivityTimeoutError({
|
||||
timeoutMs,
|
||||
idleMs: activity.idleMs,
|
||||
lastActivityAt: activity.lastActivityAt,
|
||||
waitingFor: activity.waitingFor,
|
||||
threadId,
|
||||
turnId
|
||||
turnId,
|
||||
partialAssistant: hasAssistantOutput()
|
||||
});
|
||||
appendTrace({
|
||||
type: "timeout",
|
||||
@@ -1902,30 +1882,11 @@ function createAppServerTurnState({ traceRecorder, session } = {}) {
|
||||
if (closedPromise && typeof closedPromise.then === "function") {
|
||||
candidates.push(closedPromise.then((exit) => {
|
||||
if (terminal) return terminal;
|
||||
if (hasAssistantOutput()) {
|
||||
appendTrace({
|
||||
type: "turn",
|
||||
status: "completed",
|
||||
label: "turn:completed:transport_closed_after_assistant",
|
||||
message: "Codex app-server transport closed after assistant message completion; preserving the completed assistant response.",
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "assistant-message-complete"
|
||||
});
|
||||
finish("completed", null, {
|
||||
threadId,
|
||||
turnId,
|
||||
appServerExit: exit,
|
||||
transportClosedAfterAssistant: true
|
||||
});
|
||||
return terminal;
|
||||
}
|
||||
return {
|
||||
terminalStatus: "failed",
|
||||
terminalError: "Codex app-server transport closed before turn/completed.",
|
||||
terminalError: hasAssistantOutput()
|
||||
? "Codex app-server transport closed after partial assistant output but before turn/completed."
|
||||
: "Codex app-server transport closed before turn/completed.",
|
||||
threadId,
|
||||
turnId,
|
||||
...activitySnapshot(),
|
||||
@@ -1979,8 +1940,10 @@ function appServerWaitingForMethod(method) {
|
||||
return "app-server-notification";
|
||||
}
|
||||
|
||||
function appServerActivityTimeoutError({ timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId }) {
|
||||
const error = new Error(`Codex app-server idle timeout: no new events for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`);
|
||||
function appServerActivityTimeoutError({ timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId, partialAssistant = false }) {
|
||||
const error = new Error(partialAssistant
|
||||
? `Codex app-server idle timeout after partial assistant output: no turn/completed for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`
|
||||
: `Codex app-server idle timeout: no new events for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`);
|
||||
Object.assign(error, {
|
||||
code: "codex_stdio_idle_timeout",
|
||||
timeoutMs,
|
||||
@@ -1989,6 +1952,7 @@ function appServerActivityTimeoutError({ timeoutMs, idleMs, lastActivityAt, wait
|
||||
waitingFor,
|
||||
threadId,
|
||||
turnId,
|
||||
partialAssistant,
|
||||
stage: "app-server-turn"
|
||||
});
|
||||
return error;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"web:summary": "node scripts/export-web-gate-summary.mjs",
|
||||
"web:m3-readonly": "node web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs",
|
||||
"web:m3-control-panel": "node web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs",
|
||||
"web:check": "node web/hwlab-cloud-web/scripts/check.mjs",
|
||||
"web:check": "node web/hwlab-cloud-web/scripts/check.mjs && node --test web/hwlab-cloud-web/scripts/trace-scroll.test.mjs",
|
||||
"web:build": "node web/hwlab-cloud-web/scripts/build.mjs",
|
||||
"artifact-catalog:refresh-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked",
|
||||
"g14:gitops:render": "node scripts/g14-gitops-render.mjs",
|
||||
|
||||
@@ -2759,6 +2759,7 @@ function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus
|
||||
|
||||
function hasCodeAgentConversationUxStates({ app, styles }) {
|
||||
const submitBody = `${functionBody(app, "initCommandBar")}\n${functionBody(app, "submitAgentMessage")}`;
|
||||
const applyResultBody = functionBody(app, "applyCodeAgentResultToMessage");
|
||||
const agentToneBody = functionBody(app, "agentStatusTone");
|
||||
const statusToneContract =
|
||||
/status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) ||
|
||||
@@ -2791,8 +2792,11 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
|
||||
/Code Agent 超过.*无新事件/u.test(app) &&
|
||||
/Code Agent SOURCE 回复/u.test(app) &&
|
||||
/SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) &&
|
||||
/sourceKind:\s*completion\.sourceKind/u.test(submitBody) &&
|
||||
/completion\.replied/u.test(submitBody) &&
|
||||
/sourceKind:\s*completion\.sourceKind/u.test(`${submitBody}\n${applyResultBody}`) &&
|
||||
/completion\.replied/u.test(`${submitBody}\n${applyResultBody}`) &&
|
||||
/function\s+applyCodeAgentResultToMessage\s*\(/u.test(app) &&
|
||||
/function\s+reconcileCodeAgentResult\s*\(/u.test(app) &&
|
||||
/function\s+shouldReconcileCodeAgentResult\s*\(/u.test(app) &&
|
||||
/status-source/u.test(styles) &&
|
||||
/message-user/u.test(styles) &&
|
||||
/message-agent/u.test(styles) &&
|
||||
|
||||
+234
-70
@@ -202,6 +202,7 @@ const state = {
|
||||
traceScrollPositions: new Map(),
|
||||
traceScrollUserActiveUntil: new Map(),
|
||||
fullTraceReplayInFlight: new Set(),
|
||||
resultReconciliationInFlight: new Set(),
|
||||
canceledTraces: new Set(),
|
||||
currentRequest: null,
|
||||
sessionStatus: null,
|
||||
@@ -403,6 +404,7 @@ function restoreCodeAgentSessionState() {
|
||||
state.chatMessages = Array.isArray(payload.chatMessages)
|
||||
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
|
||||
: [];
|
||||
reconcileRestoredCodeAgentResults();
|
||||
}
|
||||
|
||||
function restoreStoredChatMessage(message) {
|
||||
@@ -507,6 +509,14 @@ function refreshRestoredCodeAgentTraces() {
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileRestoredCodeAgentResults() {
|
||||
for (const message of state.chatMessages) {
|
||||
if (!message.traceId || message.role !== "agent") continue;
|
||||
if (["completed", "failed", "timeout", "canceled", "error"].includes(String(message.status ?? "").toLowerCase())) continue;
|
||||
reconcileCodeAgentResult(message.id, { quiet: true });
|
||||
}
|
||||
}
|
||||
|
||||
function initLiveBuildOverlay() {
|
||||
el.liveBuildSummary.open = false;
|
||||
el.liveBuildToggle.addEventListener("click", (event) => {
|
||||
@@ -1077,83 +1087,21 @@ async function submitAgentMessage(value, options = {}) {
|
||||
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId);
|
||||
stopTraceStream();
|
||||
if (state.canceledTraces.has(traceId)) return;
|
||||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||||
state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId;
|
||||
const resultThreadId = threadIdFrom(result);
|
||||
if (resultThreadId) state.threadId = resultThreadId;
|
||||
state.sessionStatus = result.sessionLifecycleStatus ?? result.sessionSummary?.status ?? result.session?.lifecycleStatus ?? result.session?.status ?? result.runnerTrace?.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus;
|
||||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||||
const sessionContinuity = completedSessionContinuity(result, pendingContinuity);
|
||||
const resultBlockedError = structuredBlockedErrorFromResult(result);
|
||||
const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity);
|
||||
const blockedError = resultBlockedError ?? continuityBlocker;
|
||||
const completion = classifyCodeAgentCompletion(result, { blockedError });
|
||||
const status = completion.status;
|
||||
const m3Presentation = m3IoPresentation(result);
|
||||
state.chatMessages[index] = {
|
||||
...pendingMessage,
|
||||
title: m3Presentation?.title ?? completion.title,
|
||||
text: m3Presentation?.text ?? (completion.replied
|
||||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||||
: blockedError
|
||||
? failureMessage({ ...result, error: blockedError })
|
||||
: result.status === "completed"
|
||||
? untrustedCompletionMessage(result)
|
||||
: failureMessage(result)),
|
||||
status,
|
||||
traceId: result.traceId,
|
||||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||||
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
|
||||
threadId: resultThreadId || requestedThreadId || state.threadId,
|
||||
sessionContinuity,
|
||||
messageId: result.messageId,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
backend: result.backend,
|
||||
workspace: result.workspace,
|
||||
sandbox: result.sandbox,
|
||||
session: result.session,
|
||||
sessionLifecycleStatus: result.sessionLifecycleStatus,
|
||||
sessionLifecycle: result.sessionLifecycle,
|
||||
sessionSummary: result.sessionSummary,
|
||||
sessionMode: result.sessionMode,
|
||||
sessionReuse: result.sessionReuse,
|
||||
implementationType: result.implementationType,
|
||||
runnerLimitations: result.runnerLimitations,
|
||||
codexStdioFeasibility: result.codexStdioFeasibility,
|
||||
longLivedSessionGate: result.longLivedSessionGate,
|
||||
toolCalls: result.toolCalls,
|
||||
skills: result.skills,
|
||||
runner: result.runner,
|
||||
runnerTrace: result.runnerTrace,
|
||||
conversationFacts: result.conversationFacts,
|
||||
responseType: result.responseType,
|
||||
m3Io: result.m3Io,
|
||||
m3Evidence: extractCodeAgentM3Evidence(result),
|
||||
capabilityLevel: result.capabilityLevel,
|
||||
sourceKind: completion.sourceKind,
|
||||
providerTrace: result.providerTrace,
|
||||
blocker: result.blocker,
|
||||
blockers: result.blockers,
|
||||
updatedAt: result.updatedAt,
|
||||
const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, {
|
||||
baseMessage: pendingMessage,
|
||||
pendingContinuity,
|
||||
requestedThreadId,
|
||||
retryInput: value,
|
||||
retryOf: options.retryOf ?? null,
|
||||
error: blockedError ?? result.error ?? (result.status === "completed" && !completion.replied
|
||||
? {
|
||||
code: "untrusted_completion",
|
||||
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
|
||||
}
|
||||
: undefined),
|
||||
availability: result.availability
|
||||
};
|
||||
maybeReplayFullTraceForMessage(state.chatMessages[index]);
|
||||
retryOf: options.retryOf ?? null
|
||||
});
|
||||
if (result.availability) {
|
||||
state.codeAgentAvailability = result.availability;
|
||||
}
|
||||
if (!completion.replied) {
|
||||
if (!["completed", "source"].includes(updatedMessage?.status)) {
|
||||
el.commandInput.value = value;
|
||||
}
|
||||
renderAgentChatStatus(status, state.chatMessages[index]);
|
||||
renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]);
|
||||
renderCodeAgentSummary();
|
||||
} catch (error) {
|
||||
stopTraceStream();
|
||||
@@ -1222,6 +1170,89 @@ function isTerminalSessionStatus(status) {
|
||||
return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function applyCodeAgentResultToMessage(messageId, result, options = {}) {
|
||||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0 || !result || typeof result !== "object") return null;
|
||||
const current = options.baseMessage ?? state.chatMessages[index];
|
||||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||||
state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId;
|
||||
const resultThreadId = threadIdFrom(result);
|
||||
if (resultThreadId) state.threadId = resultThreadId;
|
||||
state.sessionStatus = result.sessionLifecycleStatus ?? result.sessionSummary?.status ?? result.session?.lifecycleStatus ?? result.session?.status ?? result.runnerTrace?.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus;
|
||||
const pendingContinuity = options.pendingContinuity ?? current.sessionContinuity ?? pendingSessionContinuity({
|
||||
conversationId: current.conversationId || options.fallbackConversationId || state.conversationId,
|
||||
sessionId: current.sessionId || state.sessionId,
|
||||
threadId: current.threadId || options.fallbackThreadId || state.threadId,
|
||||
retryOf: current.retryOf
|
||||
});
|
||||
const sessionContinuity = completedSessionContinuity(result, pendingContinuity);
|
||||
const resultBlockedError = structuredBlockedErrorFromResult(result);
|
||||
const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity);
|
||||
const blockedError = resultBlockedError ?? continuityBlocker;
|
||||
const completion = classifyCodeAgentCompletion(result, { blockedError });
|
||||
const m3Presentation = m3IoPresentation(result);
|
||||
const status = completion.status;
|
||||
state.chatMessages[index] = {
|
||||
...current,
|
||||
title: m3Presentation?.title ?? completion.title,
|
||||
text: m3Presentation?.text ?? (completion.replied
|
||||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||||
: blockedError
|
||||
? failureMessage({ ...result, error: blockedError })
|
||||
: result.status === "completed"
|
||||
? untrustedCompletionMessage(result)
|
||||
: failureMessage(result)),
|
||||
status,
|
||||
traceId: result.traceId || current.traceId,
|
||||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||||
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
|
||||
threadId: resultThreadId || options.requestedThreadId || current.threadId || state.threadId,
|
||||
sessionContinuity,
|
||||
messageId: result.messageId,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
backend: result.backend,
|
||||
workspace: result.workspace,
|
||||
sandbox: result.sandbox,
|
||||
session: result.session,
|
||||
sessionLifecycleStatus: result.sessionLifecycleStatus,
|
||||
sessionLifecycle: result.sessionLifecycle,
|
||||
sessionSummary: result.sessionSummary,
|
||||
sessionMode: result.sessionMode,
|
||||
sessionReuse: result.sessionReuse,
|
||||
implementationType: result.implementationType,
|
||||
runnerLimitations: result.runnerLimitations,
|
||||
codexStdioFeasibility: result.codexStdioFeasibility,
|
||||
longLivedSessionGate: result.longLivedSessionGate,
|
||||
toolCalls: result.toolCalls,
|
||||
skills: result.skills,
|
||||
runner: result.runner,
|
||||
runnerTrace: result.runnerTrace ?? current.runnerTrace,
|
||||
conversationFacts: result.conversationFacts,
|
||||
responseType: result.responseType,
|
||||
m3Io: result.m3Io,
|
||||
m3Evidence: extractCodeAgentM3Evidence(result),
|
||||
capabilityLevel: result.capabilityLevel,
|
||||
sourceKind: completion.sourceKind,
|
||||
providerTrace: result.providerTrace,
|
||||
blocker: result.blocker,
|
||||
blockers: result.blockers,
|
||||
updatedAt: result.updatedAt ?? new Date().toISOString(),
|
||||
retryInput: options.retryInput ?? current.retryInput,
|
||||
retryOf: options.retryOf ?? current.retryOf ?? null,
|
||||
error: blockedError ?? result.error ?? (result.status === "completed" && !completion.replied
|
||||
? {
|
||||
code: "untrusted_completion",
|
||||
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
|
||||
}
|
||||
: undefined),
|
||||
availability: result.availability,
|
||||
traceReplayStatus: current.traceReplayStatus
|
||||
};
|
||||
maybeReplayFullTraceForMessage(state.chatMessages[index]);
|
||||
return state.chatMessages[index];
|
||||
}
|
||||
|
||||
function errorStatusFromPresentation(presentation, error) {
|
||||
if (presentation.category === "timeout") return "timeout";
|
||||
if (presentation.category === "canceled" || error?.code === "codex_stdio_canceled") return "canceled";
|
||||
@@ -1558,6 +1589,9 @@ function updateMessageTrace(messageId, snapshot, options = {}) {
|
||||
if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) {
|
||||
maybeReplayFullTraceForMessage(state.chatMessages[index]);
|
||||
}
|
||||
if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) {
|
||||
reconcileCodeAgentResult(messageId, { quiet: options.quiet === true });
|
||||
}
|
||||
if (options.quiet !== true) {
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
@@ -1764,6 +1798,50 @@ async function refreshTraceAfterResultPollError(traceId) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldReconcileCodeAgentResult(message, runnerTrace = message?.runnerTrace) {
|
||||
if (!message?.traceId || message.role !== "agent") return false;
|
||||
if (state.resultReconciliationInFlight.has(message.traceId)) return false;
|
||||
if (["completed", "failed", "timeout", "canceled", "error"].includes(String(message.status ?? "").toLowerCase())) return false;
|
||||
return traceHasTerminalResultEvent(runnerTrace);
|
||||
}
|
||||
|
||||
function traceHasTerminalResultEvent(runnerTrace) {
|
||||
const events = Array.isArray(runnerTrace?.events) ? runnerTrace.events : [];
|
||||
const terminalLabel = runnerTrace?.lastEvent?.label ?? events.at(-1)?.label;
|
||||
if (/^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(terminalLabel ?? ""))) return true;
|
||||
return events.some((event) => /^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(event?.label ?? "")));
|
||||
}
|
||||
|
||||
async function reconcileCodeAgentResult(messageId, options = {}) {
|
||||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0) return false;
|
||||
const message = state.chatMessages[index];
|
||||
if (!message.traceId || state.resultReconciliationInFlight.has(message.traceId)) return false;
|
||||
state.resultReconciliationInFlight.add(message.traceId);
|
||||
try {
|
||||
const response = await fetchJson(`/v1/agent/chat/result/${encodeURIComponent(message.traceId)}`, {
|
||||
timeoutMs: Math.min(API_TIMEOUT_MS, 3000),
|
||||
timeoutName: "Code Agent result reconcile"
|
||||
});
|
||||
if (!response.ok || !response.data || response.data.status === "running") return false;
|
||||
const updated = applyCodeAgentResultToMessage(messageId, response.data, {
|
||||
retryInput: message.retryInput,
|
||||
retryOf: message.retryOf,
|
||||
fallbackConversationId: message.conversationId,
|
||||
fallbackThreadId: message.threadId
|
||||
});
|
||||
if (!updated) return false;
|
||||
renderAgentChatStatus(updated.status, updated);
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
renderDrafts();
|
||||
renderRecords(state.liveSurface);
|
||||
return true;
|
||||
} finally {
|
||||
state.resultReconciliationInFlight.delete(message.traceId);
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -4170,6 +4248,9 @@ async function replayAgentTrace(messageId) {
|
||||
traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`,
|
||||
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
|
||||
};
|
||||
if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) {
|
||||
reconcileCodeAgentResult(message.id, { quiet: true });
|
||||
}
|
||||
} else {
|
||||
state.chatMessages[index] = {
|
||||
...message,
|
||||
@@ -4212,6 +4293,9 @@ async function replayFullTrace(messageId, options = {}) {
|
||||
traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`,
|
||||
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
|
||||
};
|
||||
if (shouldReconcileCodeAgentResult(state.chatMessages[currentIndex], runnerTrace)) {
|
||||
reconcileCodeAgentResult(messageId, { quiet: true });
|
||||
}
|
||||
if (options.quiet !== true) {
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
@@ -4538,6 +4622,8 @@ function installWorkbenchTestHooks() {
|
||||
window.__hwlabWorkbenchTestHooks = {
|
||||
seedTraceMessage,
|
||||
appendTraceEvents,
|
||||
reconcileTraceResultFixture,
|
||||
latestAgentMessageText,
|
||||
traceScrollMetrics,
|
||||
tracePanelText,
|
||||
setTraceScrollTop,
|
||||
@@ -4601,6 +4687,84 @@ function appendTraceEvents(count = 1) {
|
||||
renderConversation();
|
||||
}
|
||||
|
||||
async function reconcileTraceResultFixture(result) {
|
||||
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId);
|
||||
if (!message) return null;
|
||||
applyCodeAgentResultToMessage(message.id, {
|
||||
...(result && typeof result === "object" ? result : {}),
|
||||
traceId: message.traceId,
|
||||
conversationId: message.conversationId ?? "cnv_fixture",
|
||||
sessionId: message.sessionId ?? "ses_fixture",
|
||||
status: "completed",
|
||||
provider: "codex-stdio",
|
||||
model: "gpt-test",
|
||||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "workspace-write",
|
||||
capabilityLevel: "long-lived-codex-stdio-session",
|
||||
runner: {
|
||||
kind: "codex-app-server-stdio-runner",
|
||||
codexStdio: true,
|
||||
writeCapable: true,
|
||||
durableSession: true,
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "workspace-write",
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
capabilityLevel: "long-lived-codex-stdio-session"
|
||||
},
|
||||
session: {
|
||||
sessionId: message.sessionId ?? "ses_fixture",
|
||||
status: "idle",
|
||||
idleTimeoutMs: 1800000,
|
||||
lastTraceId: message.traceId,
|
||||
longLivedSession: true,
|
||||
durableSession: true,
|
||||
codexStdio: true,
|
||||
writeCapable: true
|
||||
},
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
sessionReuse: { reused: true, sessionId: message.sessionId ?? "ses_fixture" },
|
||||
longLivedSessionGate: { status: "pass", pass: true },
|
||||
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
|
||||
skills: [],
|
||||
reply: {
|
||||
content: "fixture reconciled result",
|
||||
...(result?.reply && typeof result.reply === "object" ? result.reply : {})
|
||||
},
|
||||
providerTrace: {
|
||||
protocol: "codex-app-server-jsonrpc-stdio",
|
||||
command: "codex app-server --listen stdio://",
|
||||
terminalStatus: "completed",
|
||||
...(result?.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : {})
|
||||
},
|
||||
runnerTrace: message.runnerTrace,
|
||||
updatedAt: new Date().toISOString()
|
||||
}, {
|
||||
baseMessage: message,
|
||||
retryInput: message.retryInput,
|
||||
retryOf: message.retryOf
|
||||
});
|
||||
renderCodeAgentSummary();
|
||||
renderConversation();
|
||||
return latestAgentMessageText();
|
||||
}
|
||||
|
||||
function latestAgentMessageText() {
|
||||
const message = [...state.chatMessages].reverse().find((item) => item.role === "agent");
|
||||
return message ? {
|
||||
title: message.title,
|
||||
text: message.text,
|
||||
status: message.status,
|
||||
traceId: message.traceId,
|
||||
providerTrace: message.providerTrace ?? null,
|
||||
sourceKind: message.sourceKind ?? null,
|
||||
capabilityLevel: message.capabilityLevel ?? null,
|
||||
error: message.error ?? null
|
||||
} : null;
|
||||
}
|
||||
|
||||
function testTraceEvent(traceId, seq) {
|
||||
return {
|
||||
traceId,
|
||||
|
||||
@@ -919,6 +919,10 @@ assert.match(app, /fallbackStop\s*=\s*pollRunnerTrace\(traceId,\s*messageId\)/);
|
||||
assert.match(app, /TRACE_POLL_INTERVAL_MS/);
|
||||
assert.match(app, /function updateMessageTrace/);
|
||||
assert.match(app, /function runnerTraceFromSnapshot/);
|
||||
assert.match(app, /function reconcileCodeAgentResult/);
|
||||
assert.match(app, /function shouldReconcileCodeAgentResult/);
|
||||
assert.match(app, /function reconcileRestoredCodeAgentResults/);
|
||||
assert.match(app, /traceHasTerminalResultEvent/);
|
||||
assert.match(app, /new EventSource\(`\/v1\/agent\/chat\/trace\/\$\{encodeURIComponent\(traceId\)\}\/stream`\)/);
|
||||
assert.match(app, /source\.addEventListener\("runnerTrace"/);
|
||||
assert.doesNotMatch(app, /providerTraceSummary\(message\.providerTrace\)/);
|
||||
@@ -961,6 +965,8 @@ assert.match(app, /source\.addEventListener\("runnerTrace"/);
|
||||
assert.match(app, /function pollRunnerTrace/);
|
||||
assert.match(app, /function updateMessageTrace/);
|
||||
assert.match(app, /function runnerTraceFromSnapshot/);
|
||||
assert.match(app, /function reconcileCodeAgentResult/);
|
||||
assert.match(app, /function shouldReconcileCodeAgentResult/);
|
||||
assert.doesNotMatch(app, /function messageEvidencePanel/);
|
||||
assert.doesNotMatch(app, /function messageAttributionPanel/);
|
||||
assert.doesNotMatch(app, /function codeAgentCapabilityFactsPanel/);
|
||||
|
||||
@@ -166,6 +166,152 @@ test("trace display full means complete readable timeline, not compacted result
|
||||
}
|
||||
});
|
||||
|
||||
test("trace result-ready fixture is reconciled into final assistant result", async () => {
|
||||
const server = await startStaticServer(webRoot);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1366, height: 768 },
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await login(page);
|
||||
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
|
||||
traceId: "trc_fixture_result_ready",
|
||||
events: [{
|
||||
label: "result:completed",
|
||||
type: "result",
|
||||
status: "completed",
|
||||
terminal: true,
|
||||
message: "Code Agent result is ready for short-connection polling."
|
||||
}]
|
||||
});
|
||||
});
|
||||
const before = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
|
||||
assert.equal(before.status, "running");
|
||||
assert.match(before.text, /Trace scroll contract fixture/);
|
||||
|
||||
const after = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.reconcileTraceResultFixture({
|
||||
traceId: "trc_fixture_result_ready",
|
||||
conversationId: "cnv_fixture_result_ready",
|
||||
sessionId: "ses_fixture_result_ready",
|
||||
status: "completed",
|
||||
provider: "codex-stdio",
|
||||
model: "gpt-test",
|
||||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||||
capabilityLevel: "long-lived-codex-stdio-session",
|
||||
runner: { kind: "codex-app-server-stdio-runner" },
|
||||
session: {
|
||||
sessionId: "ses_fixture_result_ready",
|
||||
status: "idle",
|
||||
idleTimeoutMs: 1800000,
|
||||
lastTraceId: "trc_fixture_result_ready",
|
||||
longLivedSession: true,
|
||||
durableSession: true,
|
||||
codexStdio: true,
|
||||
writeCapable: true
|
||||
},
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" },
|
||||
longLivedSessionGate: { status: "pass", pass: true },
|
||||
reply: { content: "最终回复来自 result endpoint,而不是 result-ready trace 文案。" },
|
||||
providerTrace: {
|
||||
protocol: "codex-app-server-jsonrpc-stdio",
|
||||
command: "codex app-server --listen stdio://",
|
||||
terminalStatus: "completed",
|
||||
threadId: "thread_fixture",
|
||||
turnId: "turn_fixture"
|
||||
}
|
||||
}));
|
||||
assert.equal(after.status, "completed", JSON.stringify(after));
|
||||
assert.match(after.text, /最终回复来自 result endpoint/);
|
||||
assert.doesNotMatch(after.text, /Code Agent result is ready for short-connection polling/);
|
||||
assert.equal(after.providerTrace.terminalStatus, "completed");
|
||||
|
||||
const cardText = await page.locator(".message-card.message-agent").last().innerText();
|
||||
assert.match(cardText, /最终回复来自 result endpoint/);
|
||||
assert.doesNotMatch(cardText, /Code Agent result is ready for short-connection polling\.$/);
|
||||
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restored pending trace polls result endpoint without token or live provider", async () => {
|
||||
const traceId = "trc_restore_result_ready";
|
||||
const server = await startStaticServer(webRoot, {
|
||||
[`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "刷新恢复后来自 result endpoint 的回复。"),
|
||||
[`/v1/agent/chat/trace/${traceId}`]: {
|
||||
traceId,
|
||||
status: "completed",
|
||||
eventCount: 1,
|
||||
events: [{
|
||||
seq: 1,
|
||||
traceId,
|
||||
label: "result:completed",
|
||||
type: "result",
|
||||
status: "completed",
|
||||
terminal: true,
|
||||
message: "Code Agent result is ready for short-connection polling."
|
||||
}],
|
||||
lastEvent: {
|
||||
label: "result:completed",
|
||||
type: "result",
|
||||
status: "completed",
|
||||
terminal: true
|
||||
}
|
||||
}
|
||||
});
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1366, height: 768 },
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
await page.addInitScript(({ traceId: injectedTraceId }) => {
|
||||
window.localStorage.setItem("hwlab.workbench.codeAgentSession.v1", JSON.stringify({
|
||||
version: 1,
|
||||
updatedAtMs: Date.now(),
|
||||
conversationId: "cnv_restore_result_ready",
|
||||
sessionId: "ses_restore_result_ready",
|
||||
threadId: "thread_restore_result_ready",
|
||||
sessionStatus: "busy",
|
||||
chatMessages: [{
|
||||
id: "msg_restore_result_ready",
|
||||
role: "agent",
|
||||
title: "Code Agent 处理中",
|
||||
text: "正在处理这次 Code Agent 请求",
|
||||
status: "running",
|
||||
traceId: injectedTraceId,
|
||||
conversationId: "cnv_restore_result_ready",
|
||||
sessionId: "ses_restore_result_ready",
|
||||
threadId: "thread_restore_result_ready",
|
||||
retryInput: "restore fixture"
|
||||
}]
|
||||
}));
|
||||
}, { traceId });
|
||||
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await login(page);
|
||||
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
|
||||
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()?.status === "completed", null, { timeout: 12000 });
|
||||
const restored = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
|
||||
assert.equal(restored.status, "completed", JSON.stringify(restored));
|
||||
assert.match(restored.text, /刷新恢复后来自 result endpoint/);
|
||||
assert.doesNotMatch(restored.text, /Code Agent result is ready for short-connection polling/);
|
||||
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function traceMetrics(page) {
|
||||
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
|
||||
}
|
||||
@@ -187,10 +333,72 @@ async function login(page) {
|
||||
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
|
||||
}
|
||||
|
||||
async function startStaticServer(rootDir) {
|
||||
function completedResultFixture(traceId, content) {
|
||||
return {
|
||||
traceId,
|
||||
conversationId: "cnv_fixture_result_ready",
|
||||
sessionId: "ses_fixture_result_ready",
|
||||
status: "completed",
|
||||
provider: "codex-stdio",
|
||||
model: "gpt-test",
|
||||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "workspace-write",
|
||||
capabilityLevel: "long-lived-codex-stdio-session",
|
||||
reply: { content },
|
||||
runner: {
|
||||
kind: "codex-app-server-stdio-runner",
|
||||
codexStdio: true,
|
||||
writeCapable: true,
|
||||
durableSession: true,
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "workspace-write",
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
capabilityLevel: "long-lived-codex-stdio-session"
|
||||
},
|
||||
session: {
|
||||
sessionId: "ses_fixture_result_ready",
|
||||
status: "idle",
|
||||
idleTimeoutMs: 1800000,
|
||||
lastTraceId: traceId,
|
||||
longLivedSession: true,
|
||||
durableSession: true,
|
||||
codexStdio: true,
|
||||
writeCapable: true
|
||||
},
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" },
|
||||
longLivedSessionGate: { status: "pass", pass: true },
|
||||
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
|
||||
skills: [],
|
||||
providerTrace: {
|
||||
protocol: "codex-app-server-jsonrpc-stdio",
|
||||
command: "codex app-server --listen stdio://",
|
||||
terminalStatus: "completed",
|
||||
threadId: "thread_fixture",
|
||||
turnId: "turn_fixture"
|
||||
},
|
||||
runnerTrace: {
|
||||
traceId,
|
||||
events: [],
|
||||
eventCount: 0,
|
||||
lastEvent: null
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function startStaticServer(rootDir, jsonRoutes = {}) {
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (Object.hasOwn(jsonRoutes, url.pathname)) {
|
||||
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(jsonRoutes[url.pathname]));
|
||||
return;
|
||||
}
|
||||
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const resolved = path.resolve(rootDir, `.${pathname}`);
|
||||
if (!resolved.startsWith(rootDir)) {
|
||||
|
||||
Reference in New Issue
Block a user