From 412caf795c7802a57192dbac8ad5d78183b47aff Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Jul 2026 18:28:02 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E5=8A=A9=E6=89=8B=20item=20?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E6=94=B6=E6=95=9B=20Trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/kafka-source-direct-debug.md | 8 ++++- tools/hwlab-cli/kafka-regenerate.test.ts | 30 +++++++++++++++++++ tools/src/hwlab-cli/trace-renderer.ts | 17 ++++++++++- .../scripts/workbench-r1-parity.test.ts | 29 ++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/docs/reference/kafka-source-direct-debug.md b/docs/reference/kafka-source-direct-debug.md index a7619c62..3ed316d8 100644 --- a/docs/reference/kafka-source-direct-debug.md +++ b/docs/reference/kafka-source-direct-debug.md @@ -13,6 +13,9 @@ - 事件使用 `reduceWorkbenchRealtimeEvent` 与 `planWorkbenchRealtimeApply` 分类; - 卡片使用 `projectWorkbenchLiveKafkaMessage` 增量归约; - Trace 行使用 `traceDisplayRows` 生成同一 row model; + - assistant、tool 与 terminal 使用同一稳定 item lifecycle 收敛可见行; + - 同一 `itemId` 已出现 `completed-agent-message` 后到达的 `agent-message-delta-progress` 不再生成第二条 assistant row; + - lifecycle 收敛只影响可见行,不删除 source/applied 事件,也不使用正文、时间戳或 DOM 去重; - Web 最后渲染 HTML,CLI 最后使用 `renderTraceRowsMarkdown` 渲染 Markdown; - Final Response 只放在 Trace 外层,Trace 内保留非最终的助手进展消息。 @@ -45,7 +48,10 @@ - 默认 JSON 只返回 `--row-limit` 指定的尾部行,并披露 returned/omitted/window; - `--full` 仅用于显式下钻完整 row payload; - `--format markdown` 输出 Markdown 正文; - - `--json` 输出身份、投影、row model、终态、计数和 artifact SHA 的结构化证据。 + - `--json` 输出身份、投影、row model、终态、计数和 artifact SHA 的结构化证据; + - `replayLineage` 分别披露 source、applied、render 与 outer final 层的计数; + - 助手重复只披露正文 SHA-256、首次 identity、`sourceSeq` 和 Kafka transport,不回显正文; + - source/applied 重复保持可见而 render 重复归零时,表示稳定 item lifecycle 已在共享 row model 收敛。 - `hwlab-cli kafka regenerate hwlab` 用于把指定 AgentRun session 的事件直接映射为 HWLAB debug 事件: - 调用生产路径同源的 AgentRun 解码与 HWLAB 事件映射函数; diff --git a/tools/hwlab-cli/kafka-regenerate.test.ts b/tools/hwlab-cli/kafka-regenerate.test.ts index 94dae8ce..3da91498 100644 --- a/tools/hwlab-cli/kafka-regenerate.test.ts +++ b/tools/hwlab-cli/kafka-regenerate.test.ts @@ -132,6 +132,36 @@ test("trace lineage reports the first non-final assistant content duplicate with assert.match(renderKafkaCliText(result.payload), /assistant=2 tool=1 terminalRows=1 final=1 duplicateLayer=source/u); }); +test("shared row model removes late assistant progress after the same item completed", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-kafka-trace-assistant-item-lifecycle-")); + const inputFile = path.join(cwd, "hwlab-events.jsonl"); + const duplicateText = "The completed assistant item must stay visible once."; + const records = [ + hwlabRecord(1, { type: "assistant", eventType: "assistant", status: "running", label: "agentrun:assistant:message", itemId: "assistant_item", assistantSource: "completed-agent-message", message: duplicateText, assistantText: duplicateText }), + hwlabRecord(2, { type: "tool", eventType: "tool", status: "completed", label: "agentrun:tool:commandExecution", toolName: "commandExecution", itemId: "tool_between", command: "rg target", exitCode: 0 }), + hwlabRecord(3, { type: "assistant", eventType: "assistant", status: "running", label: "agentrun:assistant:message", itemId: "assistant_item", assistantSource: "agent-message-delta-progress", message: duplicateText, assistantText: duplicateText }), + hwlabRecord(4, { type: "assistant", eventType: "assistant", status: "running", label: "agentrun:assistant:message", itemId: "assistant_final", assistantSource: "completed-agent-message-final", message: "Done.", assistantText: "Done.", replyAuthority: true, final: true, finalResponse: { text: "Done." } }), + hwlabRecord(5, { type: "result", eventType: "terminal", status: "completed", label: "agentrun:terminal:completed", terminal: true }) + ]; + await writeFile(inputFile, records.map((record) => JSON.stringify(record)).join("\n") + "\n", "utf8"); + + const result = await runKafkaCli([ + "render", "trace", + "--from", "jsonl", + "--trace-id", TRACE_ID, + "--jsonl-file", inputFile, + "--format", "markdown" + ], { cwd, env: {}, now: () => "2026-07-10T12:00:00.000Z" }); + + assert.equal(result.exitCode, 0); + assert.deepEqual(result.payload.replayLineage.counts, { source: 5, applied: 5, assistant: 1, tool: 1, terminal: 1, final: 1 }); + assert.equal(result.payload.replayLineage.layers.source.assistantDuplicate.occurrenceCount, 1); + assert.equal(result.payload.replayLineage.layers.applied.assistantDuplicate.occurrenceCount, 1); + assert.equal(result.payload.replayLineage.layers.render.assistantDuplicate.occurrenceCount, 0); + assert.equal(result.payload.replayLineage.firstVisibleDuplicateLayer, "source"); + assert.equal((result.markdownOutput?.match(new RegExp(duplicateText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu")) ?? []).length, 1); +}); + test("outer final response stays out of the Trace fallback when no completion event exists", () => { const finalText = "Hi. What do you want to work on?"; const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [ diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 1fa2205b..6fce9610 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -57,8 +57,8 @@ export function traceDisplayRows(trace: Record = {}, events: Tr renderedSourceEvents.add(sourceEventKey); } if (!event || isNoisyTraceEvent(event)) continue; + if (isSupersededTraceItemLifecycleEvent(event, orderedEvents, index)) continue; if (isToolTraceEvent(event)) { - if (isSupersededToolStart(event, orderedEvents, index)) continue; const identity = toolIdentity(event); if (identity) { if (renderedToolIdentities.has(identity)) continue; @@ -102,6 +102,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr if (rows.length > 0) return rows; if (orderedEvents.length === 0) return []; return orderedEvents + .filter((event, index) => !isSupersededTraceItemLifecycleEvent(event, orderedEvents, index)) .filter((event) => !isSuppressedTraceEvent(event)) .filter((event) => !isOuterFinalResponseTraceEvent(effectiveTrace, event, displayOptions, terminalAssistantFingerprints)) .map((event) => traceDisplayRow(effectiveTrace, event, displayOptions)); @@ -488,6 +489,10 @@ function traceSourceEventKey(event: TraceEvent | null | undefined): string | nul return `${source}:${sourceSeq}:${nonEmptyString(event.label ?? event.type) ?? "event"}`; } +function isSupersededTraceItemLifecycleEvent(event: TraceEvent, events: TraceEvent[], index: number): boolean { + return isSupersededToolStart(event, events, index) || isLateAssistantProgressAfterCompletion(event, events, index); +} + function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: number): boolean { if (!isToolStartTraceEvent(event)) return false; const identity = toolIdentity(event); @@ -495,6 +500,16 @@ function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: n return events.slice(index + 1).some((candidate) => candidate && isToolTraceEvent(candidate) && isToolCompleteTraceEvent(candidate) && toolIdentity(candidate) === identity); } +function isLateAssistantProgressAfterCompletion(event: TraceEvent, events: TraceEvent[], index: number): boolean { + if (!isAssistantTraceEvent(event) || nonEmptyString(event.assistantSource) !== "agent-message-delta-progress") return false; + const identity = nonEmptyString(event.itemId); + if (!identity) return false; + return events.slice(0, index).some((candidate) => candidate + && isAssistantTraceEvent(candidate) + && nonEmptyString(candidate.itemId) === identity + && nonEmptyString(candidate.assistantSource) === "completed-agent-message"); +} + function isNoisyTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event) || isAssistantTraceEvent(event)) return false; 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 014747ec..9efa81b5 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts @@ -135,6 +135,35 @@ test("pure Kafka Trace renders sourceSeq-only rows in ingress order without proj assert.deepEqual(durableRows, []); }); +test("pure Kafka Trace coalesces late assistant progress by stable item lifecycle", () => { + const events = [ + { sourceEventId: "evt_completed_a", sourceSeq: 44, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_a", assistantSource: "completed-agent-message", message: "same visible text" }, + { sourceEventId: "evt_progress_a", sourceSeq: 183, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_a", assistantSource: "agent-message-delta-progress", message: "same visible text" }, + { sourceEventId: "evt_completed_b", sourceSeq: 184, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_b", assistantSource: "completed-agent-message", message: "same visible text" }, + { sourceEventId: "evt_terminal", sourceSeq: 185, label: "agentrun:terminal:completed", type: "result", status: "completed", terminal: true } + ]; + + const rows = traceDisplayRows({ traceId: "trc_assistant_item_lifecycle", eventSource: "hwlab-kafka-sse", status: "completed" }, events); + const assistantRows = rows.filter((row) => /助手/u.test(row.header)); + + assert.deepEqual(assistantRows.map((row) => row.seq), [44, 184]); + assert.equal(assistantRows.every((row) => row.body === "same visible text"), true, "distinct itemId values must not be content-deduplicated"); + assert.equal(rows.some((row) => row.seq === 183), false); + assert.equal(rows.at(-1)?.rowId.startsWith("trace-completion:"), true); +}); + +test("pure Kafka Trace keeps item lifecycle suppression in the empty-row fallback", () => { + const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [ + { sourceEventId: "evt_completed", sourceSeq: 1, label: "agentrun:assistant:message", type: "assistant", itemId: "msg_final", assistantSource: "completed-agent-message", message: "Final response" }, + { sourceEventId: "evt_late_progress", sourceSeq: 2, label: "agentrun:assistant:message", type: "assistant", itemId: "msg_final", assistantSource: "agent-message-delta-progress", message: "Stale late progress" } + ], { + finalResponsePlacement: "outer", + outerFinalResponseText: "Final response" + }); + + assert.deepEqual(rows, []); +}); + test("R1 trace API snapshots stay authoritative over compact result traces", () => { const previous = { traceId: "trc_merge",