fix(memory-ingest): parse the current Codex response_item rollout shape

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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 09:07:27 -07:00
co-authored by Claude Fable 5
parent c2cdf65176
commit 00d0115ac7
2 changed files with 57 additions and 2 deletions
+14 -2
View File
@@ -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++;
}
}
}
+43
View File
@@ -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 });
});
});