diff --git a/build.mjs b/build.mjs index 99aaaf8..0262d70 100644 --- a/build.mjs +++ b/build.mjs @@ -40,6 +40,7 @@ const libraryEntries = [ { in: "src/services/session.ts", out: "dist/services/session.js" }, { in: "src/services/tags.ts", out: "dist/services/tags.js" }, { in: "src/services/resultMerge.ts", out: "dist/services/resultMerge.js" }, + { in: "src/services/transcript.ts", out: "dist/services/transcript.js" }, ]; await Promise.all( diff --git a/src/services/signals.ts b/src/services/signals.ts index 1d8529c..38ac6d5 100644 --- a/src/services/signals.ts +++ b/src/services/signals.ts @@ -47,6 +47,11 @@ export function groupEntriesIntoTurns(entries: TranscriptEntry[]): Turn[] { } else if (entry.role === "assistant") { currentTurn.assistantEntries.push(entry); currentTurn.allEntries.push(entry); + } else if (entry.role === "tool") { + // Tool calls/results ride along with the turn's captured content but + // aren't scanned for signal keywords — bounded tool output is noisy + // and shouldn't itself trigger a capture. + currentTurn.allEntries.push(entry); } } diff --git a/src/services/transcript.ts b/src/services/transcript.ts index 6a922cd..e006310 100644 --- a/src/services/transcript.ts +++ b/src/services/transcript.ts @@ -65,13 +65,51 @@ function searchDirForSession(dir: string, sessionId: string): string | null { return null; } +const MAX_TOOL_TEXT_LENGTH = 500; + +/** How many transcript lines apart a repeated (role, content) pair may be + * before it's treated as a distinct entry rather than the same turn logged + * twice — Codex rollout files can carry both the legacy `event_msg` shape + * and the current `response_item` shape for the same message. */ +const DUPLICATE_LINE_WINDOW = 5; + +interface ContentBlock { + type?: string; + text?: string; +} + +function extractTextBlocks(content: unknown, blockTypes: string[]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return (content as ContentBlock[]) + .filter( + (block): block is ContentBlock => + !!block && blockTypes.includes(block.type ?? "") && typeof block.text === "string", + ) + .map((block) => block.text as string) + .join("\n"); +} + +function truncateForCapture(text: string, maxLength = MAX_TOOL_TEXT_LENGTH): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}… [truncated, ${text.length - maxLength} more chars]`; +} + /** * Parse a Codex JSONL transcript file into TranscriptEntry[]. * * Codex transcript format: - * - User messages: { type: "event_msg", payload: { type: "user_message", message: "..." } } - * - Assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } - * - Also check response_item for assistant messages + * - Legacy user messages: { type: "event_msg", payload: { type: "user_message", message: "..." } } + * - Legacy assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } + * - response_item messages: { type: "response_item", payload: { type: "message", role: "user" | "assistant", content: [...] } } + * - user content blocks use `type: "input_text"` + * - assistant content blocks use `type: "output_text"` or `type: "text"` + * - response_item tool calls: { type: "response_item", payload: { type: "function_call", name, arguments, call_id } } + * - response_item tool results: { type: "response_item", payload: { type: "function_call_output", call_id, output } } + * + * The same turn is sometimes present in both the legacy and current shapes + * within one file; entries are deduped when an identical (role, content) + * pair recurs within a small line window. */ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { const entries: TranscriptEntry[] = []; @@ -80,6 +118,18 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { return entries; } + function pushEntry(index: number, role: string, rawContent: string): void { + const cleaned = cleanContent(stripPrivateContent(rawContent)); + if (!cleaned) return; + + for (let j = entries.length - 1; j >= 0; j--) { + if (index - entries[j].index > DUPLICATE_LINE_WINDOW) break; + if (entries[j].role === role && entries[j].content === cleaned) return; + } + + entries.push({ index, role, content: cleaned }); + } + try { const raw = readFileSync(transcriptPath, "utf-8"); const lines = raw.split("\n"); @@ -97,6 +147,10 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { text?: string; role?: string; content?: unknown; + name?: string; + arguments?: string; + call_id?: string; + output?: unknown; }; }; @@ -106,55 +160,33 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { // User message if (payload.type === "user_message" && payload.message) { - const cleaned = cleanContent(stripPrivateContent(payload.message)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "user", - content: cleaned, - }); - } + pushEntry(i, "user", payload.message); } // Assistant output text if (payload.type === "assistant_output_text" && payload.text) { - const cleaned = cleanContent(stripPrivateContent(payload.text)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "assistant", - content: cleaned, - }); - } + pushEntry(i, "assistant", payload.text); } } - // Handle response_item entries (assistant responses) + // Handle response_item entries if (parsed.type === "response_item" && parsed.payload) { const payload = parsed.payload; - if (payload.role === "assistant" && payload.content) { - const content = payload.content; - let text = ""; - - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - // Extract text from content blocks - for (const block of content as Array<{ type?: string; text?: string }>) { - if (block.type === "output_text" && block.text) { - text += block.text + "\n"; - } - } - } - - const cleaned = cleanContent(stripPrivateContent(text)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "assistant", - content: cleaned, - }); - } + + if (payload.role === "user" && payload.content) { + pushEntry(i, "user", extractTextBlocks(payload.content, ["input_text"])); + } else if (payload.role === "assistant" && payload.content) { + pushEntry(i, "assistant", extractTextBlocks(payload.content, ["output_text", "text"])); + } else if (payload.type === "function_call") { + const name = payload.name || "unknown_tool"; + const args = truncateForCapture(payload.arguments ?? ""); + pushEntry(i, "tool", `[tool_call] ${name}(${args})`); + } else if (payload.type === "function_call_output") { + const output = + typeof payload.output === "string" + ? payload.output + : JSON.stringify(payload.output ?? ""); + pushEntry(i, "tool", `[tool_result] ${truncateForCapture(output)}`); } } } catch { diff --git a/test/unit.mjs b/test/unit.mjs index 0e30a5f..cc720b0 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -246,6 +246,153 @@ describe("cross-container result merging", () => { }); }); +// ─── transcript parsing ───────────────────────────────────────────────────── + +describe("Codex transcript parsing", () => { + const transcriptModule = new URL("../dist/services/transcript.js", import.meta.url).href; + + function parseFixture(t, lines) { + const tmpDir = makeTmpDir(); + t.after(() => rmSync(tmpDir, { recursive: true, force: true })); + const transcriptFile = join(tmpDir, "rollout.jsonl"); + writeFileSync(transcriptFile, lines.map((l) => JSON.stringify(l)).join("\n")); + + const script = ` + import { parseTranscript } from ${JSON.stringify(transcriptModule)}; + console.log(JSON.stringify(parseTranscript(process.argv[1]))); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script, transcriptFile], { + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); + } + + test("extracts response_item user messages from input_text blocks", (t) => { + const entries = parseFixture(t, [ + { + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Please use pnpm, not npm." }], + }, + }, + ]); + assert.deepEqual( + entries.map((e) => [e.role, e.content]), + [["user", "Please use pnpm, not npm."]], + ); + }); + + test("extracts response_item assistant messages from output_text and text blocks", (t) => { + const entries = parseFixture(t, [ + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Got it," }, + { type: "text", text: "switching to pnpm." }, + ], + }, + }, + ]); + assert.deepEqual(entries.map((e) => e.role), ["assistant"]); + assert.equal(entries[0].content, "Got it,\nswitching to pnpm."); + }); + + test("captures function_call and function_call_output as bounded tool entries", (t) => { + const entries = parseFixture(t, [ + { + type: "response_item", + payload: { + type: "function_call", + call_id: "call_1", + name: "run_command", + arguments: JSON.stringify({ cmd: "pnpm install" }), + }, + }, + { + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_1", + output: "a".repeat(1000), + }, + }, + ]); + assert.equal(entries.length, 2); + assert.equal(entries[0].role, "tool"); + assert.ok(entries[0].content.startsWith("[tool_call] run_command(")); + assert.ok(entries[0].content.includes("pnpm install")); + assert.equal(entries[1].role, "tool"); + assert.ok(entries[1].content.startsWith("[tool_result] ")); + // Bounded: the 1000-char output must not appear in full. + assert.ok(entries[1].content.length < 1000); + assert.ok(entries[1].content.includes("truncated")); + }); + + test("does not double-capture a turn logged as both event_msg and response_item", (t) => { + const entries = parseFixture(t, [ + { type: "event_msg", payload: { type: "user_message", message: "What is 2+2?" } }, + { + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "What is 2+2?" }], + }, + }, + { type: "event_msg", payload: { type: "assistant_output_text", text: "4" } }, + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "4" }], + }, + }, + ]); + assert.deepEqual( + entries.map((e) => [e.role, e.content]), + [ + ["user", "What is 2+2?"], + ["assistant", "4"], + ], + ); + }); + + test("still parses legacy event_msg-only transcripts unchanged (regression guard)", (t) => { + const entries = parseFixture(t, [ + { type: "event_msg", payload: { type: "user_message", message: "What is 2+2?" } }, + { type: "event_msg", payload: { type: "assistant_output_text", text: "4" } }, + ]); + assert.deepEqual( + entries.map((e) => [e.role, e.content]), + [ + ["user", "What is 2+2?"], + ["assistant", "4"], + ], + ); + }); + + test("an identical message repeated far apart is kept as two entries", (t) => { + const filler = Array.from({ length: 10 }, (_, idx) => ({ + type: "event_msg", + payload: { type: "assistant_output_text", text: `filler ${idx}` }, + })); + const entries = parseFixture(t, [ + { type: "event_msg", payload: { type: "user_message", message: "retry" } }, + ...filler, + { type: "event_msg", payload: { type: "user_message", message: "retry" } }, + ]); + const retries = entries.filter((e) => e.content === "retry"); + assert.equal(retries.length, 2); + }); +}); + // ─── session ids ──────────────────────────────────────────────────────────── describe("session ids", () => {