From c474a19de62df57b753e4f11911022a0c0800ad6 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:32:50 +0800 Subject: [PATCH] fix: keep terminal completion trace sealed (#2122) --- tools/src/hwlab-cli/trace-renderer.ts | 6 +++-- .../scripts/workbench-r1-parity.test.ts | 27 ++++++++++--------- .../scripts/workbench-server-state.test.ts | 26 ++++++++++++++++++ .../src/stores/workbench-server-state.ts | 6 +++-- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 57924d66..3df7857e 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -23,6 +23,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr const finalResponseFingerprint = traceMessageFingerprint(finalResponseText); const hasCompletionEvent = orderedEvents.some(isCompletionTraceEvent); const rows: TraceEventRow[] = []; + let completionEvent: TraceEvent | null = null; const renderedSourceEvents = new Set(); const renderedToolIdentities = new Set(); for (let index = 0; index < orderedEvents.length; index += 1) { @@ -50,8 +51,8 @@ export function traceDisplayRows(trace: Record = {}, events: Tr continue; } if (isTerminalAssistantTraceEvent(event)) { + if (hasCompletionEvent) continue; if (finalResponseText) { - if (hasCompletionEvent) continue; rows.push(traceFinalResponseRow(event, finalResponseText, options)); } else { rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: true }, options)); @@ -64,11 +65,12 @@ export function traceDisplayRows(trace: Record = {}, events: Tr continue; } if (isCompletionTraceEvent(event)) { - rows.push(finalResponseText ? traceFinalResponseRow(event, finalResponseText, options) : traceCompletionSummaryRow(effectiveTrace, event, options)); + completionEvent ??= event; continue; } rows.push(traceDisplayRow(effectiveTrace, event, options)); } + if (completionEvent) rows.push(traceCompletionSummaryRow(effectiveTrace, completionEvent, options)); const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, orderedEvents, options); if (progressRow) rows.push(progressRow); if (rows.length > 0) return rows; diff --git a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts index 5ebc26e5..45fece7c 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts @@ -60,7 +60,7 @@ test("R1 shared trace renderer starts at the first user-meaningful event", () => { label: "agentrun:request:accepted", status: "accepted" }, { label: "agentrun:runner-job:created", status: "running", message: "runner created" }, { label: "agentrun:backend:resource-bundle-materialized", status: "running", source: "agentrun", sourceSeq: 1, message: "bundle" }, - { label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 2 } + { projectedSeq: 2, label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 2 } ]); assert.equal(rows.length, 1); assert.equal(rows[0]?.body, "OK"); @@ -71,7 +71,7 @@ test("R1 shared trace renderer suppresses AgentRun reuse diagnostics", () => { const rows = traceDisplayRows({ traceId: "trc_reuse", status: "running" }, [ { label: "agentrun:run:reused", status: "reused", message: "run reused" }, { label: "agentrun:runner-job:ensured", status: "runner-job-ensured", message: "runner ensured" }, - { label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 3 } + { projectedSeq: 3, label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 3 } ]); assert.equal(rows.length, 1); assert.equal(rows[0]?.body, "OK"); @@ -80,9 +80,9 @@ test("R1 shared trace renderer suppresses AgentRun reuse diagnostics", () => { test("R1 shared trace renderer keeps terminal assistant at its own event position", () => { const rows = traceDisplayRows({ traceId: "trc_terminal_position", status: "completed", startedAt: "2026-06-20T12:00:00.000Z" }, [ - { seq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "FINAL", createdAt: "2026-06-20T12:00:01.000Z" }, - { seq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test", createdAt: "2026-06-20T12:00:02.000Z" }, - { seq: 3, label: "agentrun:assistant:message", type: "assistant", status: "completed", message: "FINAL", final: true, replyAuthority: true, terminal: true, createdAt: "2026-06-20T12:00:03.000Z" } + { seq: 1, projectedSeq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "FINAL", createdAt: "2026-06-20T12:00:01.000Z" }, + { seq: 2, projectedSeq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test", createdAt: "2026-06-20T12:00:02.000Z" }, + { seq: 3, projectedSeq: 3, label: "agentrun:assistant:message", type: "assistant", status: "completed", message: "FINAL", final: true, replyAuthority: true, terminal: true, createdAt: "2026-06-20T12:00:03.000Z" } ]); const toolIndex = rows.findIndex((row) => row.seq === 2); const finalIndex = rows.findIndex((row) => row.seq === 3 && row.terminal === true && row.body === "FINAL"); @@ -91,21 +91,24 @@ test("R1 shared trace renderer keeps terminal assistant at its own event positio assert.equal(rows.some((row) => row.seq === 1 && row.terminal === true), false); }); -test("R1 shared trace renderer keeps completion final response at completion event position", () => { +test("R1 shared trace renderer keeps completion row last when final response is separate", () => { const rows = traceDisplayRows({ traceId: "trc_completion_position", status: "completed", startedAt: "2026-06-20T12:10:00.000Z", finalResponse: { text: "FINAL" } }, [ - { seq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "FINAL", createdAt: "2026-06-20T12:10:01.000Z" }, - { seq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test", createdAt: "2026-06-20T12:10:02.000Z" }, - { seq: 3, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, createdAt: "2026-06-20T12:10:03.000Z" } + { seq: 1, projectedSeq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "FINAL", createdAt: "2026-06-20T12:10:01.000Z" }, + { seq: 2, projectedSeq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test", createdAt: "2026-06-20T12:10:02.000Z" }, + { seq: 3, projectedSeq: 3, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, createdAt: "2026-06-20T12:10:03.000Z" }, + { seq: 4, projectedSeq: 4, label: "agentrun:assistant:message", type: "assistant", status: "completed", message: "FINAL", final: true, replyAuthority: true, terminal: true, createdAt: "2026-06-20T12:10:04.000Z" } ]); const toolIndex = rows.findIndex((row) => row.seq === 2); - const finalIndex = rows.findIndex((row) => row.seq === 3 && row.terminal === true && row.body === "FINAL"); + const completionIndex = rows.findIndex((row) => row.seq === 3 && row.rowId.startsWith("trace-completion:")); assert.ok(toolIndex >= 0); - assert.ok(finalIndex > toolIndex); + assert.equal(completionIndex, rows.length - 1); + assert.ok(completionIndex > toolIndex); + assert.equal(rows.some((row) => row.seq === 4), false); assert.equal(rows.some((row) => row.seq === 1 && row.terminal === true), false); }); test("R1 shared trace renderer keeps UTC by default and lets Web supply display clock", () => { - const events = [{ seq: 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "date", createdAt: "2026-06-17T06:34:14.586Z" }]; + const events = [{ seq: 1, projectedSeq: 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "date", createdAt: "2026-06-17T06:34:14.586Z" }]; const defaultRows = traceDisplayRows({ traceId: "trc_clock" }, events); const displayRows = traceDisplayRows({ traceId: "trc_clock" }, events, { formatClock: (value) => new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date(value)) diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index f45d22c1..9001a575 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -130,3 +130,29 @@ test("Workbench realtime agent snapshots cannot create duplicate assistant messa assert.deepEqual(selectActiveMessages(state, "ses_steer").map((message) => message.messageId), ["msg_user", "msg_agent"]); }); + +test("Workbench session messages keep sealed terminal timing on bulk refresh", () => { + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { + type: "session.messages", + sessionId: "ses_terminal_seal", + messages: [ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-24T00:00:00.000Z", sessionId: "ses_terminal_seal", traceId: "trc_terminal_seal" }, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "OK", status: "completed", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_terminal_seal", traceId: "trc_terminal_seal", timing: { startedAt: "2026-06-24T00:00:00.000Z", finishedAt: "2026-06-24T00:00:12.000Z", durationMs: 12_000, valuesRedacted: true }, startedAt: "2026-06-24T00:00:00.000Z", finishedAt: "2026-06-24T00:00:12.000Z", durationMs: 12_000 } + ] + }); + + state = reduceWorkbenchServerState(state, { + type: "session.messages", + sessionId: "ses_terminal_seal", + messages: [ + { id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-24T00:00:00.000Z", sessionId: "ses_terminal_seal", traceId: "trc_terminal_seal" }, + { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "OK", status: "completed", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_terminal_seal", traceId: "trc_terminal_seal", timing: { startedAt: "2026-06-24T00:00:00.000Z", finishedAt: "2026-06-24T00:00:13.000Z", durationMs: 13_000, valuesRedacted: true }, startedAt: "2026-06-24T00:00:00.000Z", finishedAt: "2026-06-24T00:00:13.000Z", durationMs: 13_000 } + ] + }); + + const agent = selectActiveMessages(state, "ses_terminal_seal").find((message) => message.role === "agent"); + assert.equal(agent?.durationMs, 12_000); + assert.equal(agent?.timing?.durationMs, 12_000); + assert.equal(agent?.finishedAt, "2026-06-24T00:00:12.000Z"); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index 5f9b9b49..f05157bd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -136,11 +136,13 @@ function reduceSessionListItem(state: WorkbenchServerState, session: WorkbenchSe function reduceSessionMessages(state: WorkbenchServerState, sessionId: string | null | undefined, messages: ChatMessage[]): WorkbenchServerState { if (!sessionId) return state; const existing = state.sessionsById[sessionId]; - const session = existing ? { ...existing, messages, messageCount: messages.length } : null; + const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? []; + const mergedMessages = mergeMessageList(existingMessages, messages); + const session = existing ? { ...existing, messages: mergedMessages, messageCount: mergedMessages.length } : null; return { ...state, sessionsById: session ? { ...state.sessionsById, [sessionId]: session } : state.sessionsById, - messagesBySessionId: { ...state.messagesBySessionId, [sessionId]: messages } + messagesBySessionId: { ...state.messagesBySessionId, [sessionId]: mergedMessages } }; }