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 }); + }); +});