diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 8fa719e4..a2aec6cd 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -150,6 +150,19 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对 可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期, 刷新页面不得要求用户重新登录。 +恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/` 做 +终态归一,再回放 `/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 diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index ab7bc985..25667336 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -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/` 获取终态,并用 `/v1/agent/chat/trace/` 刷新可视 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/` 是终态摘要接口,不是完整 trace 下载接口。它可以携带压缩后的 `runnerTrace` 窗口用于传输保活,但 Workbench 用户界面不得把该窗口显示为“压缩窗口”或“显示全部”。只要结果或轮询快照声明 `eventsCompacted=true`,前端必须自动请求 `/v1/agent/chat/trace/` 并用完整 trace 替换可视事件线;回放完成前只能显示“完整 trace 回放中/当前已载入”状态。result 响应仍必须保留 `eventCount`、`lastEvent`、`providerTrace`、`threadId/sessionId` 和终态 reply/blocker;完整 trace 只能从 `/v1/agent/chat/trace/`、复制 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/` 做 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 ` 或脚本内 `Set-Location -LiteralPath ` 表达,不要在 prompt 里拼 `cmd /c "cd ... && ..."`。 diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index bc07d00f..bf8fa853 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -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(); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 404d5016..6f84b064 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -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; diff --git a/package.json b/package.json index bd8a05c0..2ab97623 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index ea63b09a..cd06645e 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.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) && diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 71313a90..8e69cc39 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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, diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index cdb62c49..70928813 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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/); diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs index 13cae9aa..66b85d92 100644 --- a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -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)) {