From 00d0115ac742333c64a138cd45fdafd3103114be Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:07:27 -0700 Subject: [PATCH] fix(memory-ingest): parse the current Codex response_item rollout shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2105. Codex rollout JSONL moved to { type: 'response_item', payload: { type: 'message', role, content: [...] } }; the parser's legacy payload.message branch never fired on it, so every Codex session imported as an empty shell (message_count: 0 — 243/243 sessions on the reporting machine). Both shapes now parse; non-message response_items (reasoning etc.) are ignored. parseTranscriptJsonl exported for direct unit tests (CLI path unchanged — import.meta.main guard). Note: #2104's staging-in-gitignored-tree half is already defended on main (--include-gitignored + GIT_CEILING_DIRECTORIES, #2144, plus the #2486 reconcile guard) — verified, no change needed; it moves to the close-only roster. Co-Authored-By: Claude Fable 5 --- bin/gstack-memory-ingest.ts | 16 ++++++++++-- test/gstack-memory-ingest.test.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 4aeba0b69..5cbb535ba 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -543,7 +543,7 @@ interface ParsedSession { partial: boolean; } -function parseTranscriptJsonl(path: string): ParsedSession | null { +export function parseTranscriptJsonl(path: string): ParsedSession | null { // Best-effort tolerant parser. Handles truncated last lines (D10 partial-flag). let raw: string; try { @@ -619,7 +619,7 @@ function parseTranscriptJsonl(path: string): ParsedSession | null { const tool = rec?.name || rec?.tool || rec?.tool_call?.name || "tool"; bodyParts.push(`### Tool call: ${tool}`); } else if (isCodex && rec?.payload?.message) { - // Codex shape: each record has payload.message + // Legacy Codex shape: each record has payload.message const msg = rec.payload.message; const role = msg.role || "user"; const content = extractContentText(msg); @@ -627,6 +627,18 @@ function parseTranscriptJsonl(path: string): ParsedSession | null { bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`); messageCount++; } + } else if (isCodex && rec?.type === "response_item" && rec?.payload?.type === "message") { + // Current Codex rollout shape (#2105): records are + // { type: 'response_item', payload: { type: 'message', role, content: [...] } }. + // The legacy payload.message branch never fires on these, which rendered + // every Codex session as an empty shell (message_count: 0, 243/243 on + // the reporting machine). Flatten payload.content like the Claude branch. + const role = rec.payload.role || "user"; + const content = extractContentText(rec.payload); + if (content) { + bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`); + messageCount++; + } } } diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index 039beefad..b2d0a7b42 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -818,3 +818,46 @@ exit 0 rmSync(home, { recursive: true, force: true }); }); }); + +// #2105: current Codex rollout records are +// { type: 'response_item', payload: { type: 'message', role, content: [...] } } +// — the legacy payload.message branch never fired on them, so every Codex +// session imported as an empty shell (message_count: 0, 243/243 on the +// reporting machine). +describe("#2105 codex response_item rollout shape", () => { + it("extracts messages from response_item records", async () => { + const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest"); + const dir = mkdtempSync(join(tmpdir(), "ingest-2105-")); + const file = join(dir, "rollout-2026-06-01.jsonl"); + writeFileSync(file, [ + JSON.stringify({ type: "session_meta", payload: { id: "s1", cwd: "/tmp/x" }, timestamp: "2026-06-01T00:00:00Z" }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hello codex" }] } }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "hello human" }] } }), + // Non-message response_items must not count as messages. + JSON.stringify({ type: "response_item", payload: { type: "reasoning", summary: [] } }), + ].join("\n") + "\n"); + + const parsed = parseTranscriptJsonl(file)!; + expect(parsed).not.toBeNull(); + expect(parsed.agent).toBe("codex"); + expect(parsed.message_count).toBe(2); + expect(parsed.body).toContain("## User\n\nhello codex"); + expect(parsed.body).toContain("## Assistant\n\nhello human"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("legacy payload.message shape still parses", async () => { + const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest"); + const dir = mkdtempSync(join(tmpdir(), "ingest-2105-legacy-")); + const file = join(dir, "rollout-legacy.jsonl"); + writeFileSync(file, [ + JSON.stringify({ type: "session_meta", payload: { id: "s2", cwd: "/tmp/y" }, timestamp: "2026-06-01T00:00:00Z" }), + JSON.stringify({ payload: { message: { role: "user", content: "old shape" } } }), + ].join("\n") + "\n"); + + const parsed = parseTranscriptJsonl(file)!; + expect(parsed.message_count).toBe(1); + expect(parsed.body).toContain("## User\n\nold shape"); + rmSync(dir, { recursive: true, force: true }); + }); +});