From 2e6dfc6f9619b483cf02a5863366d89bcf8180bd Mon Sep 17 00:00:00 2001 From: hanxianfeng Date: Tue, 7 Jul 2026 11:15:57 +0800 Subject: [PATCH] fix(replay): surface compressed observation content in replay detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replay sessions built from compressed observations (the only format stored after the live-capture and jsonl-import compression pass) showed empty detail panels: every event rendered only a label and timestamp with no tool name, input, output, or prompt text. Root cause: rawFromCompressed() mapped all observation types uniformly to hookType:"post_tool_use" while leaving toolName/toolInput/toolOutput all undefined. Only conversation.userPrompt was partially handled, but the hook type was still wrong (post_tool_use instead of prompt_submit). Fix: branch on obs.type — - "conversation" → hookType:"prompt_submit", userPrompt ← narrative - everything else → hookType:"post_tool_use", toolName ← title, toolInput ← subtitle (optional), toolOutput ← narrative The compressed schema stores exactly these fields during compression (title = tool name, subtitle = raw input, narrative = combined output), so the mapping is lossless for all data retained after compression. Also exports rawFromCompressed as a pure helper for direct unit testing (precedent: src/cli/doctor-diagnostics.ts does the same). Co-Authored-By: Claude Opus 4.8 --- src/functions/replay.ts | 31 ++++++++-- test/replay.test.ts | 122 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/src/functions/replay.ts b/src/functions/replay.ts index e44ca8cc9..ddc5f89ea 100644 --- a/src/functions/replay.ts +++ b/src/functions/replay.ts @@ -46,16 +46,35 @@ async function isSymlink(path: string): Promise { } } -function rawFromCompressed(obs: CompressedObservation): RawObservation { +/** + * 将压缩后的 observation 还原为 RawObservation 结构,供 replay/timeline 使用。 + * + * 背景:运行时(含 live capture 和 jsonl-import 路径)会将所有 observation 压缩, + * 压缩后只保留 title/subtitle/narrative/facts/type 等字段,丢弃原始的 + * toolName/toolInput/toolOutput 等工具字段。 + * + * 旧版 shim 对所有类型一律返回 hookType:"post_tool_use" 并将工具字段全部置为 + * undefined,导致 projectTimeline → renderReplayDetail 收到的事件没有任何可展示 + * 的内容(detail 面板空白)。 + * + * 修复:根据 obs.type 分支处理—— + * - "conversation":映射为对话事件(hookType:"prompt_submit"),内容取 narrative。 + * - 其他(command_run / file_read / …):映射为工具调用事件 + * (hookType:"post_tool_use"),工具名取 title、输入取 subtitle、输出取 narrative。 + * + * 纯函数,无 I/O 副作用——导出供单元测试直接验证。 + */ +export function rawFromCompressed(obs: CompressedObservation): RawObservation { + const isConversation = obs.type === "conversation"; return { id: obs.id, sessionId: obs.sessionId, timestamp: obs.timestamp, - hookType: "post_tool_use", - toolName: undefined, - toolInput: undefined, - toolOutput: undefined, - userPrompt: obs.type === "conversation" ? obs.narrative : undefined, + hookType: isConversation ? "prompt_submit" : "post_tool_use", + toolName: isConversation ? undefined : obs.title, + toolInput: isConversation ? undefined : obs.subtitle, + toolOutput: isConversation ? undefined : obs.narrative, + userPrompt: isConversation ? obs.narrative : undefined, assistantResponse: undefined, raw: { title: obs.title, narrative: obs.narrative, facts: obs.facts }, }; diff --git a/test/replay.test.ts b/test/replay.test.ts index f5dfb9a6e..6c86256ce 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parseJsonlText } from "../src/replay/jsonl-parser.js"; import { projectTimeline } from "../src/replay/timeline.js"; +import { rawFromCompressed } from "../src/functions/replay.js"; +import type { CompressedObservation } from "../src/types.js"; const fx = (name: string) => readFileSync(join(__dirname, "fixtures/jsonl", name), "utf-8"); @@ -143,3 +145,123 @@ describe("projectTimeline", () => { }); }); +// 基础工厂:生成最小合法的 CompressedObservation,允许按需覆盖字段。 +function makeCompressed( + overrides: Partial & { type: CompressedObservation["type"] }, +): CompressedObservation { + return { + id: "obs-test-1", + sessionId: "sess-test", + timestamp: "2026-04-17T10:00:00.000Z", + title: "default-title", + narrative: "default narrative", + facts: [], + concepts: [], + files: [], + importance: 0.5, + ...overrides, + }; +} + +describe("rawFromCompressed", () => { + /** + * 回归测试:压缩->重建 shim 在工具类事件上必须保留可展示内容。 + * + * 旧版 shim 将所有 CompressedObservation 一律映射为 hookType:"post_tool_use" + * 但 toolName/toolInput/toolOutput 全部为 undefined,replay detail 面板因此 + * 一片空白。修复后工具类 obs 的 title/subtitle/narrative 应分别流向对应字段。 + */ + it("工具类 observation(command_run)应映射为 post_tool_use,并保留工具内容", () => { + const obs = makeCompressed({ + type: "command_run", + title: "Bash", + subtitle: "ls -la", + narrative: "README.md\nsrc\n", + facts: ["ran ls"], + }); + + const raw = rawFromCompressed(obs); + + expect(raw.hookType).toBe("post_tool_use"); + expect(raw.toolName).toBe("Bash"); + expect(raw.toolInput).toBe("ls -la"); + expect(raw.toolOutput).toBe("README.md\nsrc\n"); + expect(raw.userPrompt).toBeUndefined(); + expect(raw.assistantResponse).toBeUndefined(); + }); + + it("对话类 observation(conversation)应映射为 prompt_submit,内容取 narrative", () => { + const obs = makeCompressed({ + type: "conversation", + title: "User message", + narrative: "Fix the login bug", + facts: [], + }); + + const raw = rawFromCompressed(obs); + + expect(raw.hookType).toBe("prompt_submit"); + expect(raw.userPrompt).toBe("Fix the login bug"); + expect(raw.toolName).toBeUndefined(); + expect(raw.toolInput).toBeUndefined(); + expect(raw.toolOutput).toBeUndefined(); + expect(raw.assistantResponse).toBeUndefined(); + }); + + it("工具类 observation 在 subtitle 缺失时 toolInput 应为 undefined(不报错)", () => { + // subtitle 是 CompressedObservation 的可选字段;旧数据可能没有该字段。 + const obs = makeCompressed({ + type: "file_read", + title: "Read", + // subtitle 故意省略 + narrative: "file content here", + facts: [], + }); + + const raw = rawFromCompressed(obs); + + expect(raw.hookType).toBe("post_tool_use"); + expect(raw.toolName).toBe("Read"); + expect(raw.toolInput).toBeUndefined(); + expect(raw.toolOutput).toBe("file content here"); + }); + + it("rawFromCompressed 的输出经 projectTimeline 后 detail 字段可在事件中取到", () => { + // 端到端验证:压缩 obs → rawFromCompressed → projectTimeline → TimelineEvent + // 工具类事件的 toolName/toolInput/toolOutput 必须出现在最终的 timeline 事件里。 + const toolObs = rawFromCompressed( + makeCompressed({ + type: "command_run", + title: "Bash", + subtitle: "echo hello", + narrative: "hello", + facts: [], + }), + ); + const convObs = rawFromCompressed( + makeCompressed({ + id: "obs-test-2", + type: "conversation", + title: "User message", + narrative: "What files are here?", + facts: [], + timestamp: "2026-04-17T10:00:01.000Z", + }), + ); + + const tl = projectTimeline([toolObs, convObs]); + + // 工具结果事件 + const toolEvent = tl.events.find((e) => e.kind === "tool_result"); + expect(toolEvent).toBeDefined(); + expect(toolEvent?.toolName).toBe("Bash"); + expect(toolEvent?.toolInput).toBe("echo hello"); + expect(toolEvent?.toolOutput).toBe("hello"); + + // 对话(prompt)事件 + const promptEvent = tl.events.find((e) => e.kind === "prompt"); + expect(promptEvent).toBeDefined(); + expect(promptEvent?.body).toBe("What files are here?"); + }); +}); +