Merge pull request #488 from pikasTech/fix/code-agent-partial-result-tests

fix: harden Code Agent result lifecycle
This commit is contained in:
Lyon
2026-05-26 14:34:45 +08:00
committed by Codex
parent 1d0d9aab9f
commit 6a849596ed
9 changed files with 576 additions and 120 deletions
+13
View File
@@ -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();
+10 -46
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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)) {