Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion src/replay/jsonl-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>).function as Record<string, unknown> | 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<string, unknown>).call_id ?? (tc as Record<string, unknown>).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[] = [];
Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/jsonl/openai-format.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
45 changes: 45 additions & 0 deletions test/replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down