diff --git a/web/hwlab-cloud-web/app-trace.test.ts b/web/hwlab-cloud-web/app-trace.test.ts index 1372529a..c0ccaf7b 100644 --- a/web/hwlab-cloud-web/app-trace.test.ts +++ b/web/hwlab-cloud-web/app-trace.test.ts @@ -290,6 +290,67 @@ test("trace display rows render non-noise AgentRun tool item details", () => { assert.match(text, /当前可见 2 个 device-pod/u); }); +test("trace display rows collapse repeated AgentRun assistant snapshots and notification replay", () => { + const events = [ + event(1, "agentrun:request:accepted", { message: "accepted" }), + event(2, "agentrun:assistant:message", { + type: "assistant", + status: "running", + itemId: "msg_progress_1", + message: "我尝试拉取这个 issue 的内容。" + }), + commandEvent(3, "item/commandExecution:completed", { + itemId: "cmd_issue_read", + command: "gh api repos/pikasTech/agentrun/issues/42", + status: "completed", + exitCode: 0, + stdoutSummary: "success=true" + }), + event(4, "agentrun:assistant:message", { + type: "assistant", + status: "running", + itemId: "msg_summary", + message: "已经拉到完整内容。Issue 元信息。" + }), + event(5, "agentrun:assistant:message", { + type: "assistant", + status: "running", + itemId: "msg_summary", + message: "已经拉到完整内容。Issue 元信息。6 条评论时间线。" + }), + event(6, "agentrun:assistant:message", { + type: "assistant", + status: "running", + itemId: "msg_summary", + message: "已经拉到完整内容。Issue 元信息。6 条评论时间线。验收完成。" + }), + event(7, "agentrun:backend:turn/completed", { message: "turn/completed" }), + event(8, "agentrun:assistant:message", { + type: "assistant", + status: "running", + itemId: "msg_progress_1_replay", + message: "我尝试拉取这个 issue 的内容。" + }), + event(9, "agentrun:assistant:message", { + type: "assistant", + status: "running", + itemId: "msg_summary", + message: "已经拉到完整内容。Issue 元信息。6 条评论时间线。验收完成。" + }), + event(10, "agentrun:terminal:completed", { type: "result", terminal: true, status: "completed" }) + ]; + + const rows = traceDisplayRows({ traceId: "trc_issue_720_duplicate", events }, events); + const assistantRows = rows.filter((row) => /助手/u.test(row.header)); + const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n"); + + assert.equal(assistantRows.length, 2); + assert.deepEqual(assistantRows.map((row) => row.seq), [2, 6]); + assert.equal(matchCount(text, /我尝试拉取这个 issue 的内容/u), 1); + assert.equal(matchCount(text, /已经拉到完整内容/u), 1); + assert.match(text, /验收完成/u); +}); + function traceEvents() { return [ event(1, "request:accepted", { promptSummary: "请检查 trace" }), @@ -341,3 +402,8 @@ function event(seq, label, fields = {}) { ...fields }; } + +function matchCount(text, pattern) { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return [...String(text ?? "").matchAll(new RegExp(pattern.source, flags))].length; +} diff --git a/web/hwlab-cloud-web/app-trace.ts b/web/hwlab-cloud-web/app-trace.ts index 296a310e..a956904a 100644 --- a/web/hwlab-cloud-web/app-trace.ts +++ b/web/hwlab-cloud-web/app-trace.ts @@ -291,6 +291,8 @@ export function traceDisplayRows(trace, events) { const requestEvent = tracePrimaryRequestEvent(events); const hasTerminalAssistant = events.some((event) => isTerminalAssistantTraceEvent(event)); const terminalAssistantOnly = hasTerminalAssistant && !events.some((event) => isCommandExecutionTraceEvent(event)); + const latestAssistantByIdentity = traceLatestAssistantEventsByIdentity(events); + const renderedAssistantTextKeys = new Set(); const renderedCommands = new Set(); const renderedToolCalls = new Set(); let requestRendered = false; @@ -323,9 +325,17 @@ export function traceDisplayRows(trace, events) { continue; } if (isAssistantMessageTraceEvent(event)) { - const row = traceAssistantSummaryRow(trace, event, assistantIndex); - assistantIndex += 1; - if (row) rows.push(row); + const assistantIdentity = traceAssistantMessageIdentity(event); + if (assistantIdentity && latestAssistantByIdentity.get(assistantIdentity) !== event) continue; + const assistantText = traceAssistantTextForEvent(trace, event, assistantIndex); + const assistantTextKey = traceAssistantComparableText(assistantText); + if (!isTerminalAssistantTraceEvent(event) && assistantTextKey && renderedAssistantTextKeys.has(assistantTextKey)) continue; + const row = traceAssistantSummaryRow(trace, event, assistantIndex, assistantText); + if (row) { + rows.push(row); + assistantIndex += 1; + if (assistantTextKey) renderedAssistantTextKeys.add(assistantTextKey); + } continue; } if (isRequestTraceEvent(event)) { @@ -416,6 +426,38 @@ function traceGenericToolCallGroups(events) { return groups; } +function traceLatestAssistantEventsByIdentity(events) { + const latest = new Map(); + const firstCompletionIndex = events.findIndex((event) => isCompletionTraceEvent(event)); + for (const event of events) { + if (!isAssistantMessageTraceEvent(event)) continue; + const identity = traceAssistantMessageIdentity(event); + if (!identity) continue; + const previous = latest.get(identity); + if (!previous || traceAssistantEventShouldReplace(previous, event, firstCompletionIndex, events)) latest.set(identity, event); + } + return latest; +} + +function traceAssistantEventShouldReplace(previous, event, firstCompletionIndex, events) { + if (isTerminalAssistantTraceEvent(event)) return true; + if (isTerminalAssistantTraceEvent(previous)) return false; + const previousAfterCompletion = traceEventAfterIndex(previous, firstCompletionIndex, events); + const eventAfterCompletion = traceEventAfterIndex(event, firstCompletionIndex, events); + if (previousAfterCompletion !== eventAfterCompletion) return previousAfterCompletion && !eventAfterCompletion; + return true; +} + +function traceEventAfterIndex(event, index, events) { + if (index < 0) return false; + const eventIndex = events.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq)); + return eventIndex > index; +} + +function traceAssistantMessageIdentity(event) { + return traceNonEmptyString(event?.itemId ?? event?.messageId ?? event?.id); +} + function traceCommandGroupKey(event, fallback = null) { return traceNonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`; } @@ -689,9 +731,9 @@ function traceCompletionSummaryRow(trace, event) { }; } -function traceAssistantSummaryRow(trace, event, index = 0) { +function traceAssistantSummaryRow(trace, event, index = 0, textOverride = undefined) { const terminal = isTerminalAssistantTraceEvent(event); - const text = traceAssistantTextForEvent(trace, event, index); + const text = textOverride === undefined ? traceAssistantTextForEvent(trace, event, index) : textOverride; if (!text && !terminal && traceHasLaterTerminalAssistant(trace, event)) return null; const elapsed = formatTraceDuration(traceRelativeMs(trace, event)); return {