diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index dc63aef0..5d6979d8 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -435,11 +435,8 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op const fetchImpl = options.fetchImpl ?? globalThis.fetch; const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); - const eventsResponse = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/events?afterSeq=${encodeURIComponent(String(mapped.agentRun.lastSeq ?? 0))}&limit=500`, { - method: "GET", - timeoutMs - }); - const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : []; + const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } }); + const events = eventsResponse.events; appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun); const result = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands/${encodeURIComponent(mapped.agentRun.commandId)}/result`, { method: "GET", @@ -448,7 +445,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op const nextMapping = { ...mapped.agentRun, ...agentRunResultRefs(result), - lastSeq: Math.max(Number(mapped.agentRun.lastSeq ?? 0), Number(result?.lastSeq ?? 0), ...events.map((event) => Number(event?.seq ?? 0))), + lastSeq: agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq), status: result?.status ?? mapped.agentRun.status ?? "running", runStatus: result?.runStatus ?? mapped.agentRun.runStatus ?? null, commandState: result?.commandState ?? mapped.agentRun.commandState ?? null, @@ -469,13 +466,10 @@ export async function refreshAgentRunTrace({ traceId, result = null, options = { const fetchImpl = options.fetchImpl ?? globalThis.fetch; const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); - const eventsResponse = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/events?afterSeq=${encodeURIComponent(String(mapped.agentRun.lastSeq ?? 0))}&limit=500`, { - method: "GET", - timeoutMs - }); - const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : []; + const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } }); + const events = eventsResponse.events; appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun); - const lastSeq = Math.max(Number(mapped.agentRun.lastSeq ?? 0), ...events.map((event) => Number(event?.seq ?? 0))); + const lastSeq = agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq); if (lastSeq !== Number(mapped.agentRun.lastSeq ?? 0)) { options.codeAgentChatResults?.set?.(traceId, { ...mapped, agentRun: { ...mapped.agentRun, lastSeq, updatedAt: nowIso(options.now) } }); } @@ -1293,11 +1287,64 @@ function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {}) } } +async function fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping = {} }) { + const runId = requiredString(mapping.runId, "runId"); + const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : ""; + const { afterSeq, endSeq } = agentRunTraceReplayWindow(mapping); + const path = `/api/v1/runs/${encodeURIComponent(runId)}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=500`; + const response = await agentRunJson(fetchImpl, managerUrl, path, { method: "GET", timeoutMs }); + const rawEvents = Array.isArray(response?.items) ? response.items : []; + const events = rawEvents.filter((event) => agentRunEventBelongsToTrace(event, { currentCommandId, afterSeq, endSeq })); + return { + events, + afterSeq, + endSeq, + commandFiltered: Boolean(currentCommandId), + maxSeq: Math.max(afterSeq, ...rawEvents.map((event) => Number(event?.seq ?? 0))), + traceLastSeq: Math.max(afterSeq, ...events.map((event) => Number(event?.seq ?? 0)).filter(Number.isFinite)) + }; +} + +function agentRunTraceCursorSeq(eventsResponse = {}, previousLastSeq = 0) { + const afterSeq = Number(eventsResponse.afterSeq ?? 0); + const endSeq = Number(eventsResponse.endSeq ?? 0); + const traceLastSeq = Number(eventsResponse.traceLastSeq ?? 0); + if (Number.isFinite(traceLastSeq) && traceLastSeq > afterSeq) return Math.floor(traceLastSeq); + if (Number.isFinite(endSeq) && endSeq > 0) return Math.floor(endSeq); + if (eventsResponse.commandFiltered === true) return Math.max(Number(previousLastSeq ?? 0), afterSeq); + return Math.max(Number(previousLastSeq ?? 0), Number(eventsResponse.maxSeq ?? 0)); +} + +function agentRunTraceReplayWindow(mapping = {}) { + const eventStartSeq = Number(mapping.eventStartSeq ?? mapping.commandStartSeq ?? mapping.startSeq ?? 0); + const summary = mapping.traceSummary && typeof mapping.traceSummary === "object" ? mapping.traceSummary : null; + const summaryAgentRun = summary?.agentRun && typeof summary.agentRun === "object" ? summary.agentRun : null; + const summaryLastSeq = Number(summaryAgentRun?.lastSeq ?? summary?.lastSeq ?? 0); + const currentLastSeq = Number(mapping.lastSeq ?? 0); + const endSeq = Number.isFinite(summaryLastSeq) && summaryLastSeq > 0 ? Math.floor(summaryLastSeq) : 0; + if (Number.isFinite(eventStartSeq) && eventStartSeq > 0) return { afterSeq: Math.max(0, Math.floor(eventStartSeq) - 1), endSeq }; + if (endSeq > 0) return { afterSeq: Math.max(0, endSeq - 500), endSeq }; + if (Number.isFinite(currentLastSeq) && currentLastSeq > 0) return { afterSeq: Math.floor(currentLastSeq), endSeq: 0 }; + return { afterSeq: 0, endSeq: 0 }; +} + +function agentRunEventBelongsToTrace(event, { currentCommandId = "", afterSeq = 0, endSeq = 0 } = {}) { + const eventCommandId = agentRunEventCommandId(event); + const seq = Number(event?.seq ?? 0); + if (currentCommandId && eventCommandId && eventCommandId !== currentCommandId) return false; + if (endSeq > 0 && Number.isFinite(seq)) return seq > afterSeq && seq <= endSeq; + return true; +} + +function agentRunEventCommandId(event) { + const payload = event?.payload && typeof event.payload === "object" ? event.payload : {}; + return typeof payload.commandId === "string" ? payload.commandId : ""; +} + function isForeignAgentRunCommandEvent(event, mapping = {}) { const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : ""; if (!currentCommandId) return false; - const payload = event?.payload && typeof event.payload === "object" ? event.payload : {}; - const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : ""; + const eventCommandId = agentRunEventCommandId(event); return Boolean(eventCommandId && eventCommandId !== currentCommandId); } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index b568dbbb..1b754adb 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1662,6 +1662,102 @@ test("cloud api trace returns persisted summary when live trace store has expire } }); +test("cloud api trace replays an earlier AgentRun command after same-run lastSeq advances (#955)", async () => { + const calls = []; + const firstTraceId = "trc_issue955_first_trace"; + const codeAgentChatResults = new Map(); + const runId = "run_issue955_replay"; + const firstCommandId = "cmd_issue955_first"; + const secondCommandId = "cmd_issue955_second"; + const firstFinalText = "目前只有一个 HWPOD 可用:\n\n- d601-f103-v2 (board: D601-F103-V2 / STM32F103)\n\nAPI 返回 count=1, availableCount=1。"; + const firstEvents = [ + { id: "evt_issue955_first_setup", runId, seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: firstCommandId, jobName: "agentrun-v01-runner-issue955-first", namespace: "agentrun-v01" }, createdAt: "2026-06-06T01:43:25.000Z" }, + { id: "evt_issue955_first_tool", runId, seq: 18, type: "tool_call", payload: { method: "item/completed", type: "commandExecution", toolName: "commandExecution", itemId: "call_issue955_hwpod_list", command: "/bin/sh -lc 'hwpod list --available'", status: "completed", exitCode: 0, outputSummary: '{"count":1,"availableCount":1,"ids":["d601-f103-v2"]}', commandId: firstCommandId }, createdAt: "2026-06-06T01:43:42.000Z" }, + { id: "evt_issue955_first_assistant", runId, seq: 27, type: "assistant_message", payload: { commandId: firstCommandId, itemId: "msg_item_0", text: firstFinalText }, createdAt: "2026-06-06T01:43:55.000Z" }, + { id: "evt_issue955_first_terminal", runId, seq: 35, type: "terminal_status", payload: { commandId: firstCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:43:56.000Z" } + ]; + const secondEvents = [ + { id: "evt_issue955_second_setup", runId, seq: 47, type: "backend_status", payload: { phase: "runner-claim-waiting-for-stale-lease" }, createdAt: "2026-06-06T01:45:56.000Z" }, + { id: "evt_issue955_second_assistant", runId, seq: 58, type: "assistant_message", payload: { commandId: secondCommandId, itemId: "msg_item_0", text: "编译成功!来总结一下结果。" }, createdAt: "2026-06-06T01:46:24.000Z" }, + { id: "evt_issue955_second_terminal", runId, seq: 66, type: "terminal_status", payload: { commandId: secondCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:28.000Z" } + ]; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + calls.push({ method: request.method, path: url.pathname, afterSeq: url.searchParams.get("afterSeq") }); + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data: { items: [...firstEvents, ...secondEvents] }, traceId: "trc_fake_issue955_replay" })}\n`); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + codeAgentChatResults.set(firstTraceId, { + accepted: true, + status: "completed", + shortConnection: true, + traceId: firstTraceId, + conversationId: "cnv_issue955_replay", + sessionId: "ses_issue955_replay", + threadId: "thread-issue955-replay", + ownerUserId: TEST_AGENT_ACTOR.id, + ownerRole: TEST_AGENT_ACTOR.role, + reply: { role: "assistant", content: firstFinalText }, + finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: firstTraceId, valuesPrinted: false }, + traceSummary: { traceId: firstTraceId, source: "agent-session-snapshot", sourceEventCount: 41, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false }, + agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: firstCommandId, lastSeq: 66, terminalStatus: "completed", valuesPrinted: false }, + valuesPrinted: false + }); + const traceStore = createCodeAgentTraceStore(); + const server = createCloudApiServer({ + traceStore, + codeAgentChatResults, + env: { + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${firstTraceId}`, { + headers: { cookie: "hwlab_session=test-stub-session" } + }); + assert.equal(response.status, 200); + const body = await response.json(); + const text = JSON.stringify(body); + assert.equal(body.traceId, firstTraceId); + assert.equal(body.status, "completed"); + assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/events` && call.afterSeq === "0")); + assert.ok(body.events.some((event) => event.commandId === firstCommandId && event.label === "item/commandExecution:completed")); + assert.ok(body.events.some((event) => event.commandId === firstCommandId && event.label === "agentrun:assistant:message")); + assert.equal(body.events.some((event) => event.commandId === secondCommandId), false); + assert.equal(body.finalResponse.text, firstFinalText); + assert.match(text, /d601-f103-v2/u); + assert.doesNotMatch(text, /编译成功/u); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + agentRunServer.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-")); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index b27c6b31..dc6ffb2e 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1835,8 +1835,8 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) } function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId, refreshError = null) { - if (snapshot?.status !== "missing") return snapshot; const fallback = persistentTraceFallback(persistedResult, traceId); + if (snapshot?.status !== "missing") return traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError); if (!fallback) return { ...snapshot, ok: false, @@ -1878,6 +1878,31 @@ function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId, }; } +function traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError = null) { + if (!fallback) return snapshot; + return { + ...snapshot, + conversationId: snapshot.conversationId ?? fallback.conversationId, + sessionId: snapshot.sessionId ?? fallback.sessionId, + threadId: snapshot.threadId ?? fallback.threadId, + agentRun: snapshot.agentRun ?? fallback.agentRun, + finalResponse: snapshot.finalResponse ?? fallback.finalResponse, + traceSummary: snapshot.traceSummary ?? fallback.traceSummary, + fallback: snapshot.fallback ?? (refreshError ? { + available: true, + source: fallback.source, + refresh: { + attempted: true, + ok: false, + code: refreshError?.code ?? "agentrun_trace_refresh_failed", + message: refreshError?.message ?? "AgentRun trace refresh failed; live events may be partial.", + valuesPrinted: false + }, + valuesPrinted: false + } : undefined) + }; +} + function persistentTraceFallback(result, traceId) { if (!result || typeof result !== "object") return null; const storedSummary = result.traceSummary && typeof result.traceSummary === "object" ? result.traceSummary : null; diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index a65063b5..d76bf99d 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -68,6 +68,10 @@ export function traceDisplayRows(trace: Record = {}, events: Tr rows.push(traceDisplayRow(trace, event)); } if (completionEvent) { + const finalResponseText = traceFinalResponseText(trace); + if (finalResponseText) { + lastAssistantRowIndex = upsertAuthoritativeFinalResponseRow(rows, assistantRows, completionEvent, finalResponseText); + } const lastAssistantRow = lastAssistantRowIndex >= 0 ? rows[lastAssistantRowIndex] : undefined; if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent); else rows.push(traceCompletionSummaryRow(trace, completionEvent)); @@ -78,6 +82,13 @@ export function traceDisplayRows(trace: Record = {}, events: Tr interface AssistantRowState { rowIndex: number; comparableText: string; + sourceEvent: TraceEvent; + derivedSnapshotSuffix?: boolean; +} + +interface AssistantSnapshotDecision { + action: "replace" | "append-suffix" | "keep-existing"; + suffixText?: string; } function traceToolCallRow(trace: Record, event: TraceEvent): TraceEventRow { @@ -157,7 +168,7 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista const comparableText = comparableAssistantText(row.body); if (!comparableText) { rows.push(row); - assistantRows.push({ rowIndex: rows.length - 1, comparableText }); + assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: event }); return rows.length - 1; } @@ -168,11 +179,31 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista if (!existingRow) continue; if (state.comparableText === comparableText) { rows[state.rowIndex] = mergeAssistantRows(existingRow, row); + state.sourceEvent = event; + state.derivedSnapshotSuffix = false; return state.rowIndex; } if (comparableText.includes(state.comparableText)) { + const previousIndex = comparableText.indexOf(state.comparableText); + if (state.derivedSnapshotSuffix === true && previousIndex === 0) { + rows[state.rowIndex] = mergeAssistantRows(row, existingRow); + state.comparableText = comparableText; + state.sourceEvent = event; + state.derivedSnapshotSuffix = false; + return state.rowIndex; + } + const snapshotDecision = assistantSnapshotDecision(comparableText, state.comparableText, event, state.sourceEvent); + if (snapshotDecision.action === "append-suffix" && snapshotDecision.suffixText) { + const suffixRow = { ...row, body: snapshotDecision.suffixText }; + rows.push(suffixRow); + assistantRows.push({ rowIndex: rows.length - 1, comparableText: snapshotDecision.suffixText, sourceEvent: event, derivedSnapshotSuffix: true }); + return rows.length - 1; + } + if (snapshotDecision.action === "keep-existing") return state.rowIndex; rows[state.rowIndex] = mergeAssistantRows(row, existingRow); state.comparableText = comparableText; + state.sourceEvent = event; + state.derivedSnapshotSuffix = false; return state.rowIndex; } if (state.comparableText.includes(comparableText)) { @@ -182,10 +213,67 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista } rows.push(row); - assistantRows.push({ rowIndex: rows.length - 1, comparableText }); + assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: event }); return rows.length - 1; } +function upsertAuthoritativeFinalResponseRow(rows: TraceEventRow[], assistantRows: AssistantRowState[], completionEvent: TraceEvent, finalText: string): number { + const comparableText = comparableAssistantText(finalText); + let matchedState: AssistantRowState | null = null; + for (const state of assistantRows) { + const row = rows[state.rowIndex]; + if (!row || row.bodyFormat !== "markdown") continue; + row.terminal = undefined; + if (!matchedState && state.comparableText === comparableText) matchedState = state; + } + if (matchedState) { + const row = rows[matchedState.rowIndex]; + rows[matchedState.rowIndex] = { + ...row, + body: finalText, + terminal: true, + tone: "ok", + header: `${traceClock(completionEvent.createdAt)} 助手最终消息` + }; + matchedState.comparableText = comparableText; + matchedState.derivedSnapshotSuffix = false; + return matchedState.rowIndex; + } + rows.push({ + rowId: `trace-final-response:${completionEvent.seq ?? "completed"}`, + seq: numberOrNull(completionEvent.seq), + tone: "ok", + header: `${traceClock(completionEvent.createdAt)} 助手最终消息`, + terminal: true, + body: finalText, + bodyFormat: "markdown" + }); + assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: completionEvent }); + return rows.length - 1; +} + +function assistantSnapshotDecision(nextText: string, previousText: string, nextEvent: TraceEvent, previousEvent: TraceEvent): AssistantSnapshotDecision { + if (!sameAssistantSnapshotIdentity(nextEvent, previousEvent)) return { action: "replace" }; + if (isAuthoritativeAssistantEvent(nextEvent) || isAuthoritativeAssistantEvent(previousEvent)) return { action: "replace" }; + const previousIndex = nextText.indexOf(previousText); + if (previousIndex < 0) return { action: "replace" }; + const suffixText = nextText.slice(previousIndex + previousText.length).trim(); + if (suffixText && suffixText.length >= Math.max(80, previousText.length / 3)) return { action: "append-suffix", suffixText }; + if (previousIndex > 0 || !suffixText) return { action: "keep-existing" }; + return { action: "replace" }; +} + +function sameAssistantSnapshotIdentity(left: TraceEvent, right: TraceEvent): boolean { + const leftItem = nonEmptyString(left.itemId ?? left.messageId ?? left.id); + const rightItem = nonEmptyString(right.itemId ?? right.messageId ?? right.id); + if (leftItem && rightItem) return leftItem === rightItem; + return false; +} + +function isAuthoritativeAssistantEvent(event: TraceEvent): boolean { + return event.replyAuthority === true || event.final === true || event.terminal === true || String(event.status ?? "") === "completed"; +} + function mergeAssistantRows(preferred: TraceEventRow, metadata: TraceEventRow): TraceEventRow { return { ...preferred, @@ -199,6 +287,11 @@ function comparableAssistantText(value: unknown): string { return cleanTraceText(value).replace(/\s+/gu, " "); } +function traceFinalResponseText(trace: Record): string | null { + const response = trace.finalResponse && typeof trace.finalResponse === "object" ? trace.finalResponse as Record : null; + return nonEmptyString(response?.text ?? response?.content ?? response?.message); +} + function markAssistantRowTerminal(trace: Record, row: TraceEventRow, completionEvent: TraceEvent): TraceEventRow { return { ...row, diff --git a/web/hwlab-cloud-web/src/state/trace-renderer.test.ts b/web/hwlab-cloud-web/src/state/trace-renderer.test.ts index 7c433d8f..a188c1b4 100644 --- a/web/hwlab-cloud-web/src/state/trace-renderer.test.ts +++ b/web/hwlab-cloud-web/src/state/trace-renderer.test.ts @@ -66,6 +66,31 @@ test("web trace rows do not fold progress assistant text into the final response assert.doesNotMatch(terminalRows[0]?.body ?? "", /Let me check|当前工作区还没有/u); }); +test("web trace rows keep clean AgentRun final response when same itemId snapshots stay running (#955)", () => { + const progressOne = "好,我来走 HWPOD 编译路径。先看看 hwpod 命令是否可用,并读取 hwpod-cli 技能了解完整的编译流程。"; + const progressTwo = "好,现在用 hwpod build 走编译。根据 skill,标准路径是 hwpod build 触发 hwpod-compiler-cli,再通过 API 发给 hwpod-node。"; + const finalText = "编译成功!来总结一下结果:\n\n hwpod build ✅ completed\n\n- 设备: D601-F103-V2 (STM32F103)\n- 工具链: Keil MDK,通过 keil-cli.py build 异步发起\n- 构建命令: py -3 keil-cli.py build -p atk_f103.uvprojx -t USART\n- exit code: 0,任务已入队列\n- 预期产物: F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Output\\atk_f103.hex\n- 无 blocker"; + const events: Record[] = [ + { seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-06T01:46:11.000Z" }, + { seq: 17, source: "agentrun", sourceSeq: 48, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: progressOne, createdAt: "2026-06-06T01:46:15.000Z" }, + { seq: 22, source: "agentrun", sourceSeq: 53, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: progressTwo, createdAt: "2026-06-06T01:46:18.000Z" }, + { seq: 26, source: "agentrun", sourceSeq: 57, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: `${progressOne}${progressTwo}${finalText.slice(0, 170)}`, createdAt: "2026-06-06T01:46:24.000Z" }, + { seq: 27, source: "agentrun", sourceSeq: 58, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: finalText, createdAt: "2026-06-06T01:46:25.000Z" }, + { seq: 30, source: "agentrun", sourceSeq: 61, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: `${progressOne}${progressTwo}${finalText}`, createdAt: "2026-06-06T01:46:26.000Z" }, + { seq: 36, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling.", createdAt: "2026-06-06T01:46:28.000Z" } + ]; + + const rows = traceDisplayRows({ startedAt: "2026-06-06T01:46:11.000Z", finalResponse: { text: finalText } }, events); + const assistantRows = rows.filter((row) => row.bodyFormat === "markdown"); + const terminalRows = assistantRows.filter((row) => row.terminal === true); + + assert.equal(terminalRows.length, 1); + assert.equal(terminalRows[0]?.body, finalText); + assert.doesNotMatch(terminalRows[0]?.body ?? "", /好,我来走 HWPOD 编译路径|好,现在用 hwpod build/u); + assert.equal(assistantRows.filter((row) => /好,我来走 HWPOD 编译路径/u.test(row.body ?? "")).length, 1); + assert.equal(assistantRows.filter((row) => /好,现在用 hwpod build/u.test(row.body ?? "")).length, 1); +}); + test("web trace rows collapse tool start/completed and clean shell command escaping", () => { const events: Record[] = [ { seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T12:13:58.212Z" },