fix: 按助手 item 生命周期收敛 Trace

This commit is contained in:
root
2026-07-11 18:28:02 +02:00
parent d79f9da683
commit 412caf795c
4 changed files with 82 additions and 2 deletions
+7 -1
View File
@@ -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 最后渲染 HTMLCLI 最后使用 `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 事件映射函数;
+30
View File
@@ -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" }, [
+16 -1
View File
@@ -57,8 +57,8 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, 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<string, unknown> = {}, 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;
@@ -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",