From fb01e183a0510c5b4a933f727339a06e06c15c93 Mon Sep 17 00:00:00 2001 From: Gold Date: Sat, 4 Jul 2026 10:23:06 -0300 Subject: [PATCH] feat(replay): support OpenAI chat-format JSONL in parseJsonlText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes Agent and other OpenAI-compatible agents emit JSONL transcripts in the OpenAI chat format (top-level role/content, tool_calls arrays, role:"tool" messages). The replay parser only recognized Claude Code's format (type discriminator, structured content arrays), producing zero observations from these transcripts. Add a normalizeOpenAIEntry() pre-processing step that converts the four OpenAI message shapes to Claude Code equivalents before the existing parser logic runs. Entries already in Claude Code format pass through untouched, so the change is backward-compatible. Mapped patterns: - {role:"user", content} → type:"user" + message.content - {role:"tool", tool_call_id, content} → type:"user" + tool_result block - {role:"assistant", tool_calls} → type:"assistant" + tool_use blocks - {role:"assistant", content} → type:"assistant" + text block Tested with a real Hermes session: 0 → 49 observations extracted. All 19 existing replay tests + 2 new tests pass. --- src/replay/jsonl-parser.ts | 80 ++++++++++++++++++++++++- test/fixtures/jsonl/openai-format.jsonl | 4 ++ test/replay.test.ts | 45 ++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/jsonl/openai-format.jsonl 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", () => {