diff --git a/src/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts index 5060c3451..7faa45391 100644 --- a/src/replay/jsonl-parser.ts +++ b/src/replay/jsonl-parser.ts @@ -7,6 +7,10 @@ interface JsonlEntry { sessionId?: string; timestamp?: string; cwd?: string; + role?: string; + content?: unknown; + tool_calls?: unknown[]; + tool_call_id?: string; message?: { role?: string; content?: unknown; @@ -78,6 +82,78 @@ function extractToolResults(content: unknown): Array<{ toolUseId: string; output return out; } +/** + * Normalize entries emitted in OpenAI chat-format JSONL (as produced by Hermes, + * OpenAI-compatible agents, and other tools that log raw API requests) to the + * Claude Code transcript format that {@link parseJsonlText} already understands. + * + * The OpenAI chat format uses top-level `role` + `content` fields and a separate + * `tool_calls` array on assistant messages, with tool results carried in + * `role:"tool"` messages. Claude Code uses a `type` discriminator and packs + * tool_use / tool_result into structured `message.content` arrays. + * + * Entries already in Claude Code format (those with `entry.type` or + * `entry.message`) pass through untouched. + */ +function normalizeOpenAIEntry(entry: JsonlEntry): JsonlEntry { + // Already in Claude Code format — leave as-is. + if (entry.type || entry.message) return entry; + + const role = entry.role as string | undefined; + const content = entry.content; + + // { role: "user", content: "..." } + if (role === "user" && content !== undefined) { + return { ...entry, type: "user", message: { role: "user", content } }; + } + + // { role: "tool", tool_call_id: "...", content: "..." } + if (role === "tool") { + const toolUseId = typeof entry.tool_call_id === "string" ? entry.tool_call_id : ""; + return { + ...entry, + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content }], + }, + }; + } + + // { role: "assistant", content: "...", tool_calls: [...] } + if (role === "assistant") { + const textContent = typeof content === "string" && content.trim() ? content : undefined; + const contentArr: unknown[] = []; + if (textContent) contentArr.push({ type: "text", text: textContent }); + + if (Array.isArray(entry.tool_calls)) { + for (const tc of entry.tool_calls) { + if (!tc || typeof tc !== "object") continue; + const fn = (tc as Record).function as Record | undefined; + const rawArgs = fn?.arguments; + let input: unknown = rawArgs; + if (typeof rawArgs === "string") { + try { input = JSON.parse(rawArgs); } catch { input = { raw: rawArgs }; } + } + contentArr.push({ + type: "tool_use", + id: (tc as Record).call_id ?? (tc as Record).id ?? "", + name: fn?.name ?? "unknown", + input: input ?? {}, + }); + } + } + + // Only restructure if we found something to carry; otherwise leave untouched + // so a bare {role:"assistant"} with no content doesn't create a phantom entry. + if (contentArr.length > 0) { + return { ...entry, type: "assistant", message: { role: "assistant", content: contentArr } }; + } + } + + return entry; +} + export function parseJsonlText(text: string, fallbackSessionId?: string): ParsedTranscript { const lines = text.split("\n").filter((l) => l.trim().length > 0); const entries: JsonlEntry[] = []; @@ -97,7 +173,9 @@ export function parseJsonlText(text: string, fallbackSessionId?: string): Parsed const observations: RawObservation[] = []; - for (const entry of entries) { + for (const rawEntry of entries) { + const entry = normalizeOpenAIEntry(rawEntry); + if (entry.sessionId && !sessionId) sessionId = entry.sessionId; if (entry.cwd && !cwd) cwd = entry.cwd; const ts = entry.timestamp || new Date().toISOString(); diff --git a/test/fixtures/jsonl/openai-format.jsonl b/test/fixtures/jsonl/openai-format.jsonl new file mode 100644 index 000000000..ea3c53943 --- /dev/null +++ b/test/fixtures/jsonl/openai-format.jsonl @@ -0,0 +1,4 @@ +{"role":"user","content":"Fix the login bug","sessionId":"sess-openai","timestamp":"2026-04-17T10:00:00.000Z","cwd":"/Users/alice/project"} +{"role":"assistant","content":"Let me check the auth module.","tool_calls":[{"id":"call_1","call_id":"call_1","type":"function","function":{"name":"Bash","arguments":"{\"command\":\"cat src/auth.ts\"}"}}],"sessionId":"sess-openai","timestamp":"2026-04-17T10:00:05.000Z"} +{"role":"tool","tool_call_id":"call_1","content":"export function login() { ... }","sessionId":"sess-openai","timestamp":"2026-04-17T10:00:06.000Z"} +{"role":"assistant","content":"Found the bug — missing await on the token refresh.","sessionId":"sess-openai","timestamp":"2026-04-17T10:00:10.000Z"} diff --git a/test/replay.test.ts b/test/replay.test.ts index f5dfb9a6e..d5eaf2b74 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -97,6 +97,51 @@ describe("parseJsonlText", () => { const out = parseJsonlText(text, "fb-used"); expect(out.sessionId).toBe("fb-used"); }); + + it("parses OpenAI chat-format JSONL (role/content/tool_calls)", () => { + const out = parseJsonlText(fx("openai-format.jsonl")); + expect(out.sessionId).toBe("sess-openai"); + // user prompt → assistant text+tool_call → tool result → assistant text + const kinds = out.observations.map((o) => o.hookType); + expect(kinds).toEqual([ + "prompt_submit", + "stop", // assistant text "Let me check…" + "pre_tool_use", // Bash tool_call from same assistant message + "post_tool_use", // tool result + "stop", // final assistant text + ]); + // user prompt extracted from { role: "user", content: "..." } + expect(out.observations[0].userPrompt).toBe("Fix the login bug"); + // tool_call extracted from assistant.tool_calls[].function + const toolCall = out.observations[2]; + expect(toolCall.toolName).toBe("Bash"); + expect((toolCall.toolInput as { command: string }).command).toBe("cat src/auth.ts"); + // tool_result extracted from { role: "tool", tool_call_id, content } + const toolResult = out.observations[3]; + expect(toolResult.toolOutput).toBe("export function login() { ... }"); + // final assistant text-only message + expect(out.observations[4].assistantResponse).toContain("Found the bug"); + }); + + it("does not break Claude Code format when OpenAI entries are mixed in", () => { + const mixed = [ + JSON.stringify({ + type: "user", + sessionId: "sess-mixed", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "claude-format" }] }, + }), + JSON.stringify({ + role: "user", + content: "openai-format", + timestamp: "2026-01-01T00:00:01.000Z", + }), + ].join("\n"); + const out = parseJsonlText(mixed); + expect(out.observations).toHaveLength(2); + expect(out.observations[0].userPrompt).toBe("claude-format"); + expect(out.observations[1].userPrompt).toBe("openai-format"); + }); }); describe("projectTimeline", () => {