Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions src/functions/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,35 @@ async function isSymlink(path: string): Promise<boolean> {
}
}

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 },
};
Expand Down
122 changes: 122 additions & 0 deletions test/replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -143,3 +145,123 @@ describe("projectTimeline", () => {
});
});

// 基础工厂:生成最小合法的 CompressedObservation,允许按需覆盖字段。
function makeCompressed(
overrides: Partial<CompressedObservation> & { 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?");
});
});