Merge remote-tracking branch 'origin/main' into johnnyeric/kilo-opencode-v1.17.13

# Conflicts:
#	packages/core/src/filesystem/search.ts
#	packages/opencode/src/command/index.ts
This commit is contained in:
Johnny Eric Amancio
2026-08-04 18:36:43 +02:00
79 changed files with 5603 additions and 448 deletions
@@ -0,0 +1,22 @@
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello, can you help me read a file?"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Of course! Let me read that for you."},{"type":"tool_use","id":"toolu_01","name":"read","input":{"file_path":"src/index.ts"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export const x = 1;\nexport const y = 2;"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"I can see the file contents. Now let me edit it."},{"type":"tool_use","id":"toolu_02","name":"edit","input":{"file_path":"src/index.ts","old_str":"export const x = 1;","new_str":"export const x = 42;"}},{"type":"tool_use","id":"toolu_03","name":"bash","input":{"command":"bun run typecheck"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_005","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"File edited successfully."},{"type":"tool_result","tool_use_id":"toolu_03","content":"Typecheck passed with no errors."}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_006","role":"assistant","content":[{"type":"text","text":"The edit was applied and typecheck passed. Is there anything else?"}]}}
{"type":"ai-title","version":"2.42.0","isSidechain":true,"message":{"id":"title_001","role":"assistant","content":[{"type":"text","text":"File read and edit session"}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_007","role":"user","content":[{"type":"text","text":"What is the capital of France?"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_008","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking a simple factual question. I should answer directly.","signature":"sig_test123"},{"type":"text","text":"The capital of France is Paris."}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_009","role":"user","content":[{"type":"text","text":"Now run a complex command with multiple tool calls."}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_010","role":"assistant","content":[{"type":"text","text":"Sure, running now."},{"type":"tool_use","id":"toolu_04","name":"bash","input":{"command":"ls -la"}},{"type":"tool_use","id":"toolu_05","name":"bash","input":{"command":"git status"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_011","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_04","content":"total 8\ndrwxr-xr-x 3 user staff 96 Jan 1 12:00 .\ndrwxr-xr-x 5 user staff 160 Jan 1 12:00 ..\n-rw-r--r-- 1 user staff 42 Jan 1 12:00 index.ts"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_012","role":"assistant","content":[{"type":"text","text":"One command succeeded. The other result might have been lost."}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_013","role":"user","content":[{"type":"text","text":"This message repeats IDs intentionally."}]}}
{"type":"last-prompt","version":"2.42.0","isSidechain":true,"message":{"id":"lp_001","role":"user","content":[{"type":"text","text":"This message repeats IDs intentionally."}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_014","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{"file_path":"src/index.ts"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_015","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export const x = 42;\nexport const y = 2;"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_016","role":"assistant","content":[{"type":"text","text":"Done reading with repeated ID."}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_017","role":"user","content":[{"type":"text","text":"Try something unsupported."},{"type":"server_tool_result","tool_use_id":"toolu_06","content":"raw server output"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_018","role":"assistant","content":[{"type":"redacted_thinking","data":"encrypted_data_test"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_019","role":"assistant","content":[{"type":"custom_block","custom_field":"some_value"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_020","role":"assistant","content":[{"type":"tool_use","id":"toolu_06","name":"search","input":{"query":"test"}}]}}
@@ -0,0 +1,27 @@
{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/Users/test/project","model_provider":"openai","session_id":"ses_test001"}}
{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Read src/index.ts and then edit it."}]}}
{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"call_01","name":"read","arguments":"{\"file_path\":\"src/index.ts\"}"}}
{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"call_01","output":"export const x = 1;\nexport const y = 2;"}}
{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done reading. Now editing."}]}}
{"type":"response_item","payload":{"id":"item_005","type":"function_call","call_id":"call_02","name":"edit","arguments":"{\"file_path\":\"src/index.ts\",\"old_str\":\"export const x = 1;\",\"new_str\":\"export const x = 42;\"}"}}
{"type":"response_item","payload":{"id":"item_006","type":"function_call","call_id":"call_03","name":"bash","arguments":"{\"command\":\"bun run typecheck\"}"}}
{"type":"response_item","payload":{"id":"item_007","type":"function_call_output","call_id":"call_02","output":"File edited successfully."}}
{"type":"response_item","payload":{"id":"item_008","type":"function_call_output","call_id":"call_03","output":"Typecheck passed with no errors."}}
{"type":"response_item","payload":{"id":"item_009","type":"message","role":"assistant","content":[{"type":"output_text","text":"The edit was applied and typecheck passed."}]}}
{"type":"turn_context","payload":{}}
{"type":"response_item","payload":{"id":"item_010","type":"message","role":"user","content":[{"type":"input_text","text":"What is the capital of France?"}]}}
{"type":"response_item","payload":{"id":"item_011","type":"message","role":"assistant","content":[{"type":"output_text","text":"The capital of France is Paris."}]}}
{"type":"response_item","payload":{"id":"item_012","type":"message","role":"user","content":[{"type":"input_text","text":"Now run multiple commands."}]}}
{"type":"response_item","payload":{"id":"item_013","type":"function_call","call_id":"call_04","name":"bash","arguments":"{\"command\":\"ls -la\"}"}}
{"type":"response_item","payload":{"id":"item_014","type":"function_call","call_id":"call_05","name":"bash","arguments":"{\"command\":\"git status\"}"}}
{"type":"response_item","payload":{"id":"item_015","type":"function_call_output","call_id":"call_04","output":"total 8\ndrwxr-xr-x 3 user staff 96 Jan 1 12:00 ."}}
{"type":"response_item","payload":{"id":"item_016","type":"message","role":"user","content":[{"type":"input_text","text":"One result was lost. Continue."}]}}
{"type":"response_item","payload":{"id":"item_017","type":"message","role":"assistant","content":[{"type":"output_text","text":"Only one command result arrived."}]}}
{"type":"response_item","payload":{"id":"item_018","type":"message","role":"user","content":[{"type":"input_text","text":"Repeated call IDs."}]}}
{"type":"response_item","payload":{"id":"item_019","type":"function_call","call_id":"call_01","name":"read","arguments":"{\"file_path\":\"src/index.ts\"}"}}
{"type":"response_item","payload":{"id":"item_020","type":"function_call_output","call_id":"call_01","output":"export const x = 42;\nexport const y = 2;"}}
{"type":"response_item","payload":{"id":"item_021","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done with repeated call ID."}]}}
{"type":"response_item","payload":{"id":"item_022","type":"message","role":"user","content":[{"type":"input_text","text":"Unsupported content."}]}}
{"type":"response_item","payload":{"id":"item_023","type":"reasoning","summary":[]}}
{"type":"event_msg","payload":{}}
{"type":"response_item","payload":{"id":"item_024","type":"message","role":"assistant","content":[]}}
@@ -0,0 +1,245 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { SessionResume } from "../../../src/kilocode/session-resume"
import { ProviderTransform } from "../../../src/provider/transform"
import { MessageV2 } from "../../../src/session/message-v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import type { Provider } from "../../../src/provider/provider"
import type { Part as V1Part, WithParts } from "@opencode-ai/core/v1/session"
function cacheType(message: { providerOptions?: unknown }) {
const anthropic = message.providerOptions as { anthropic?: { cacheControl?: { type?: unknown } } } | undefined
return anthropic?.anthropic?.cacheControl?.type
}
// ── Fixtures ──────────────────────────────────────────────────────────────
const claudeFixture = () =>
Bun.file(path.join(__dirname, "../fixture/session-resume/claude.jsonl")).text()
const codexFixture = () =>
Bun.file(path.join(__dirname, "../fixture/session-resume/codex.jsonl")).text()
// ── Model helpers ────────────────────────────────────────────────────────
function openaiModel(id: string, opts: { reasoning?: boolean } = {}): Provider.Model {
return {
id: ModelV2.ID.make(id),
providerID: ProviderV2.ID.make("openai"),
api: { id, url: "https://api.openai.com/v1", npm: "@ai-sdk/openai" },
name: "OpenAI Test",
capabilities: {
temperature: true,
reasoning: opts.reasoning ?? false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: { context: 128000, output: 32000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2025-01-01",
}
}
function anthropicModel(id: string): Provider.Model {
return {
id: ModelV2.ID.make(id),
providerID: ProviderV2.ID.make("anthropic"),
api: { id, url: "https://api.anthropic.com/v1", npm: "@ai-sdk/anthropic" },
name: "Anthropic Test",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: { context: 200000, output: 32000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2025-01-01",
}
}
// ── Transcript helpers ───────────────────────────────────────────────────
const base = {
sessionID: "ses_cache",
agent: "build",
providerID: "openai",
modelID: "gpt-5",
directory: "/test",
worktree: "/test",
}
function castToWithParts(mapped: SessionResume.MappedMessage[]): WithParts[] {
return mapped.map((m) => ({
info: m.info as WithParts["info"],
parts: m.parts as V1Part[],
}))
}
// ── Helper: parse fixture through the full resume pipeline ────────────────
/** Parse a fixture JSONL, map through SessionResume, convert to model messages. */
async function buildModelMessages(model: Provider.Model, fixtureName: "claude" | "codex") {
const content = fixtureName === "claude" ? await claudeFixture() : await codexFixture()
const transcript = SessionResume.parseLines(content)
const { messages } = SessionResume.mapTranscript(transcript, {
...base,
...(transcript.sourceModel ? { sourceModel: transcript.sourceModel } : {}),
})
const msgs = castToWithParts(messages)
return MessageV2.toModelMessages(msgs, model)
}
// ── Cache-proof tests ─────────────────────────────────────────────────────
describe("MessageV2.toModelMessages + ProviderTransform cache continuity", () => {
for (const fixture of ["claude", "codex"] as const) {
test(`keeps the ${fixture} imported prefix and session cache key across two OpenAI requests`, async () => {
const model = openaiModel("gpt-5")
const imported = await buildModelMessages(model, fixture)
const firstOptions = ProviderTransform.options({ model, sessionID: base.sessionID })
const laterOptions = ProviderTransform.options({ model, sessionID: base.sessionID })
const firstInput = [...structuredClone(imported), { role: "user" as const, content: "Continue from import." }]
const laterInput = [
...structuredClone(imported),
{ role: "user" as const, content: "Continue from import." },
{ role: "assistant" as const, content: "The imported history is available." },
{ role: "user" as const, content: "Use a different model for this next turn." },
]
const first = ProviderTransform.message(firstInput, model, firstOptions)
const later = ProviderTransform.message(laterInput, model, laterOptions)
expect(firstOptions.promptCacheKey).toBe(base.sessionID)
expect(laterOptions.promptCacheKey).toBe(base.sessionID)
expect(JSON.stringify(first.slice(0, imported.length))).toBe(JSON.stringify(later.slice(0, imported.length)))
})
}
test("sets store:false for OpenAI models", () => {
const model = openaiModel("gpt-5")
const opts = ProviderTransform.options({ model, sessionID: "ses_test" })
expect(opts.store).toBe(false)
})
test("fixture-derived messages preserve transcript text exactly (claude fixture)", async () => {
const model = openaiModel("gpt-5")
const modelMsgs = await buildModelMessages(model, "claude")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const transformed = ProviderTransform.message(modelMsgs, model, opts)
expect(transformed.length).toBeGreaterThan(0)
const raw = JSON.stringify(transformed)
// First user message from the claude fixture
expect(raw).toContain("Hello, can you help me read a file")
// A later user message from the fixture
expect(raw).toContain("What is the capital of France")
})
test("fixture-derived messages preserve transcript text exactly (codex fixture)", async () => {
const model = openaiModel("gpt-5")
const modelMsgs = await buildModelMessages(model, "codex")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const transformed = ProviderTransform.message(modelMsgs, model, opts)
expect(transformed.length).toBeGreaterThan(0)
const raw = JSON.stringify(transformed)
// First user message from the codex fixture
expect(raw).toContain("Read src/index.ts and then edit it")
expect(raw).toContain("What is the capital of France")
})
})
describe("ProviderTransform.message Anthropic caching", () => {
const SYSTEM = { role: "system" as const, content: "You are a test assistant." }
test("marks the prepended system message with cache control (claude fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "claude")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const sys = transformed.find((m) => m.role === "system")
expect(sys).toBeDefined()
expect(cacheType(sys!)).toBe("ephemeral")
})
test("marks exactly the final two non-system messages with cache control (claude fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "claude")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const nonSystem = transformed.filter((m) => m.role !== "system")
expect(nonSystem.length).toBeGreaterThanOrEqual(2)
const last = nonSystem[nonSystem.length - 1]
const prev = nonSystem[nonSystem.length - 2]
expect(cacheType(last)).toBe("ephemeral")
expect(cacheType(prev)).toBe("ephemeral")
// No other non-system message carries cache control
for (let i = 0; i < nonSystem.length - 2; i++) {
expect(cacheType(nonSystem[i])).toBeUndefined()
}
})
test("marks the prepended system message with cache control (codex fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "codex")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const sys = transformed.find((m) => m.role === "system")
expect(sys).toBeDefined()
expect(cacheType(sys!)).toBe("ephemeral")
})
test("marks exactly the final two non-system messages with cache control (codex fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "codex")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const nonSystem = transformed.filter((m) => m.role !== "system")
expect(nonSystem.length).toBeGreaterThanOrEqual(2)
const last = nonSystem[nonSystem.length - 1]
const prev = nonSystem[nonSystem.length - 2]
expect(cacheType(last)).toBe("ephemeral")
expect(cacheType(prev)).toBe("ephemeral")
for (let i = 0; i < nonSystem.length - 2; i++) {
expect(cacheType(nonSystem[i])).toBeUndefined()
}
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,980 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { SessionResume } from "../../src/kilocode/session-resume"
// ── UUID validation ───────────────────────────────────────────────────
describe("SessionResume.isUUID", () => {
test("accepts valid UUID v4", () => {
expect(SessionResume.isUUID("550e8400-e29b-41d4-a716-446655440000")).toBe(true)
})
test("rejects non-UUID string", () => {
expect(SessionResume.isUUID("not-a-uuid")).toBe(false)
expect(SessionResume.isUUID("")).toBe(false)
expect(SessionResume.isUUID("g1234567-1234-1234-1234-1234567890ab")).toBe(false)
})
})
describe("SessionResume.validateUUID", () => {
test("does not throw for valid UUID", () => {
expect(() => SessionResume.validateUUID("550e8400-e29b-41d4-a716-446655440000")).not.toThrow()
})
test("throws for invalid UUID", () => {
expect(() => SessionResume.validateUUID("bad")).toThrow(SessionResume.ParseError)
})
})
// ── Detection ────────────────────────────────────────────────────────
describe("SessionResume.detect", () => {
test("detects Claude format from real top-level envelope", () => {
const line = {
type: "user",
version: "2.42.0",
isSidechain: false,
message: { id: "msg_001", role: "user", content: [{ type: "text", text: "hello" }] },
}
expect(SessionResume.detect(line)).toBe("claude")
})
test("detects Claude format from assistant with tool_use", () => {
const line = {
type: "assistant",
version: "2.42.0",
isSidechain: false,
message: {
id: "msg_002",
role: "assistant",
content: [{ type: "tool_use", id: "toolu_01", name: "read", input: {} }],
},
}
expect(SessionResume.detect(line)).toBe("claude")
})
test("detects Codex format from session_meta", () => {
const line = { type: "session_meta", payload: { cli_version: "0.8.0", cwd: "/test" } }
expect(SessionResume.detect(line)).toBe("codex")
})
test("detects Codex format from response_item", () => {
const line = {
type: "response_item",
payload: {
id: "item_001",
type: "message",
role: "user",
content: [{ type: "input_text", text: "hello" }],
},
}
expect(SessionResume.detect(line)).toBe("codex")
})
test("returns undefined for non-message objects", () => {
expect(SessionResume.detect(null)).toBeUndefined()
expect(SessionResume.detect("string")).toBeUndefined()
expect(SessionResume.detect({})).toBeUndefined()
expect(SessionResume.detect([])).toBeUndefined()
})
})
// ── Claude parsing ───────────────────────────────────────────────────
describe("Claude parseLines", () => {
test("parses simple user+assistant text exchange with real envelopes", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi there!"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.format).toBe("claude")
expect(result.version).toBe(2)
expect(result.steps).toHaveLength(2)
expect(result.steps[0]).toEqual({
role: "user",
parts: [{ type: "text", text: "Hello" }],
})
expect(result.steps[1]).toEqual({
role: "assistant",
parts: [{ type: "text", text: "Hi there!" }],
})
})
test("accepts string user content and ignores transcript metadata", () => {
const text = [
'{"type":"permission-mode","sessionId":"session"}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":"Hello"}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
expect(SessionResume.parseLines(text).steps).toEqual([
{ role: "user", parts: [{ type: "text", text: "Hello" }] },
{ role: "assistant", parts: [{ type: "text", text: "Hi" }] },
])
})
test("keeps a real user turn beside paired tool results", () => {
const text = [
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"text","text":"Start"}]}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool","content":"done"},{"type":"text","text":"Continue"}]}}',
].join("\n")
expect(SessionResume.parseLines(text).steps.map((step) => step.role)).toEqual(["user", "assistant", "user"])
expect(SessionResume.parseLines(text).steps[2]?.parts).toEqual([{ type: "text", text: "Continue" }])
})
test("keeps a partially paired tool step before a mixed later user turn", () => {
const text = [
'{"type":"user","version":"2.42.0","message":{"role":"user","content":"Start"}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"tool_use","id":"first","name":"read","input":{}},{"type":"tool_use","id":"second","name":"edit","input":{}}]}}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"first","content":"done"},{"type":"text","text":"Also say hi"}]}}',
].join("\n")
const steps = SessionResume.parseLines(text).steps
expect(steps.map((step) => step.role)).toEqual(["user", "assistant", "user"])
expect(steps[1]?.parts.map((part) => part.type)).toEqual(["tool_call", "tool_call", "tool_result", "error"])
expect(steps[2]?.parts).toEqual([{ type: "text", text: "Also say hi" }])
})
test("normalizes object tool results", () => {
const claude = [
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"text","text":"Start"}]}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool","content":{"value":1}}]}}',
].join("\n")
const claudeTool = SessionResume.parseLines(claude).steps[1]?.parts.find((part) => part.type === "tool_result")
expect(claudeTool).toMatchObject({ content: '{"value":1}' })
})
test("reads version from real Claude envelope", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.version).toBe(2)
})
test("rejects unsupported Claude major version", () => {
const text = [
'{"type":"user","version":"3.0.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("rejects Claude records without version field", () => {
const text = [
'{"type":"user","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow("missing version")
})
test("pairs tool_use with tool_result", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Read file"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"toolu_01","name":"read","input":{"file":"foo.ts"}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"file contents"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts).toEqual([{ type: "text", text: "Read file" }])
expect(result.steps[1].role).toBe("assistant")
const tc = result.steps[1].parts.find((p): p is SessionResume.ToolCall => p.type === "tool_call")
const tr = result.steps[1].parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tc).toBeDefined()
expect(tc!.id).toBe("toolu_01")
expect(tc!.name).toBe("read")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("toolu_01")
expect(tr!.content).toBe("file contents")
expect(result.steps[2].role).toBe("assistant")
expect(result.steps[2].parts).toEqual([{ type: "text", text: "Done." }])
})
test("pairs multiple tool calls with results in one round", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Do two things"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"call_a","name":"read","input":{}},{"type":"tool_use","id":"call_b","name":"edit","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"call_a","content":"result A"},{"type":"tool_result","tool_use_id":"call_b","content":"result B"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"Both done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const calls = mid.parts.filter((p): p is SessionResume.ToolCall => p.type === "tool_call")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(calls).toHaveLength(2)
expect(results).toHaveLength(2)
expect(calls.map((c) => c.id).sort()).toEqual(["call_a", "call_b"])
expect(results.map((r) => r.callID).sort()).toEqual(["call_a", "call_b"])
})
test("merges consecutive assistant messages into one pending step", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Go"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"a","name":"read","input":{}}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"assistant","content":[{"type":"tool_use","id":"b","name":"edit","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"user","content":[{"type":"tool_result","tool_use_id":"a","content":"ra"},{"type":"tool_result","tool_use_id":"b","content":"rb"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_005","role":"assistant","content":[{"type":"text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const calls = mid.parts.filter((p): p is SessionResume.ToolCall => p.type === "tool_call")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(calls).toHaveLength(2)
expect(results).toHaveLength(2)
expect(calls.map((c) => c.id).sort()).toEqual(["a", "b"])
expect(results.map((r) => r.callID).sort()).toEqual(["a", "b"])
})
test("excludes sidechain records from parsed history", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"user","version":"2.42.0","isSidechain":true,"message":{"id":"side_001","role":"user","content":[{"type":"text","text":"Sidechain"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(2)
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts).toEqual([{ type: "text", text: "Hello" }])
expect(result.steps[1].role).toBe("assistant")
expect(result.steps[1].parts).toEqual([{ type: "text", text: "Hi" }])
})
test("skips known metadata record types", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"ai-title","version":"2.42.0","isSidechain":true,"message":{"id":"title_001","role":"assistant","content":[{"type":"text","text":"Session title"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(2)
})
test("handles unpaired tool call in final step", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Run"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"orphan_1","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"text","text":"New topic"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const errors = mid.parts.filter((p): p is SessionResume.ErrorPart => p.type === "error")
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("Unpaired tool call")
expect(errors[0].message).toContain("orphan_1")
})
test("handles repeated tool_use IDs across different rounds", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Read twice"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{"file":"a.ts"}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"content a"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"First read done."}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_005","role":"user","content":[{"type":"text","text":"Read again"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_006","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{"file":"b.ts"}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_007","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"content b"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_008","role":"assistant","content":[{"type":"text","text":"Second read done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const toolSteps = result.steps.filter(
(s) => s.role === "assistant" && s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps).toHaveLength(2)
for (const step of toolSteps) {
const tr = step.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("toolu_01")
}
})
test("parses reasoning/thinking blocks from real envelope", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Q"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"thinking","thinking":"Let me think...","signature":"sig_test"},{"type":"text","text":"Answer"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const assistant = result.steps[1]
expect(assistant.role).toBe("assistant")
expect(assistant.parts).toHaveLength(2)
expect(assistant.parts[0]).toEqual({ type: "reasoning", text: "Let me think..." })
expect(assistant.parts[1]).toEqual({ type: "text", text: "Answer" })
})
test("handles redacted_thinking as unsupported", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Test"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"redacted_thinking","data":"encrypted"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
const assistant = result.steps[1]
const unsupportedParts = assistant.parts.filter(
(p): p is SessionResume.Unsupported => p.type === "unsupported",
)
expect(unsupportedParts).toHaveLength(1)
expect(unsupportedParts[0].reason).toBe("encrypted reasoning block")
})
test("handles unknown content types as unsupported", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Test"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"custom_block","data":"unknown"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
const assistant = result.steps[1]
const unsupportedParts = assistant.parts.filter(
(p): p is SessionResume.Unsupported => p.type === "unsupported",
)
expect(unsupportedParts).toHaveLength(1)
expect(unsupportedParts[0].reason).toContain("unknown content type")
expect(unsupportedParts[0].reason).toContain("custom_block")
})
test("counts server_tool_result as unsupported in tool-result message", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Go"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"ok"},{"type":"server_tool_result","tool_use_id":"toolu_99","content":"raw"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const unsupportedParts = mid.parts.filter(
(p): p is SessionResume.Unsupported => p.type === "unsupported",
)
expect(unsupportedParts).toHaveLength(1)
expect(unsupportedParts[0].reason).toBe("server_tool_result block")
})
test("rejects empty input", () => {
expect(() => SessionResume.parseLines("")).toThrow(SessionResume.ParseError)
expect(() => SessionResume.parseLines(" \n ")).toThrow(SessionResume.ParseError)
})
test("rejects malformed JSON", () => {
expect(() =>
SessionResume.parseLines(
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"hi"}]}}\n{broken',
),
).toThrow(SessionResume.ParseError)
})
test("rejects a malformed JSON Lines file without returning partial history", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "resume-jsonl-"))
try {
const file = path.join(directory, "transcript.jsonl")
fs.writeFileSync(
file,
[
'{"type":"user","version":"2.42.0","message":{"role":"user","content":"Start"}}',
"invalid-json",
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"text","text":"Done"}]}}',
].join("\n"),
)
await expect(SessionResume.parse(file)).rejects.toThrow("Line 2: invalid JSON")
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
})
// ── Codex parsing ────────────────────────────────────────────────────
describe("Codex parseLines", () => {
test("parses simple user+assistant text exchange with real envelopes", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi there!"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.format).toBe("codex")
expect(result.version).toBe(0)
expect(result.steps).toHaveLength(2)
expect(result.steps[0]).toEqual({
role: "user",
parts: [{ type: "text", text: "Hello" }],
})
expect(result.steps[1]).toEqual({
role: "assistant",
parts: [{ type: "text", text: "Hi there!" }],
})
})
test("reads version from session_meta payload", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.version).toBe(0)
})
test("rejects Codex file without session_meta", () => {
const text = [
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("rejects unsupported Codex major version", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"1.0.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("starts a new assistant step after a completed function call", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Read file"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"call_1","name":"read","arguments":"{\\"file\\":\\"foo.ts\\"}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"call_1","output":"file contents"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const tc = mid.parts.find((p): p is SessionResume.ToolCall => p.type === "tool_call")
const tr = mid.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tc).toBeDefined()
expect(tc!.id).toBe("call_1")
expect(tc!.name).toBe("read")
expect(tc!.input).toEqual({ file: "foo.ts" })
expect(tr).toBeDefined()
expect(tr!.callID).toBe("call_1")
expect(tr!.content).toBe("file contents")
expect(result.steps[2]).toEqual({ role: "assistant", parts: [{ type: "text", text: "Done." }] })
})
test("one user with two complete tool rounds produces exact steps in source order", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Read then edit"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"c1","name":"read","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"c1","output":"result1"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done reading. Now editing."}]}}',
'{"type":"response_item","payload":{"id":"item_005","type":"function_call","call_id":"c2","name":"edit","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_006","type":"function_call_output","call_id":"c2","output":"result2"}}',
'{"type":"response_item","payload":{"id":"item_007","type":"message","role":"assistant","content":[{"type":"output_text","text":"All done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(4)
// Step 0: user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts).toEqual([{ type: "text", text: "Read then edit" }])
// Step 1: first assistant tool round
expect(result.steps[1].role).toBe("assistant")
const step1Types = result.steps[1].parts.map((p) => p.type)
expect(step1Types).toEqual(["tool_call", "tool_result"])
const step1Call = result.steps[1].parts[0] as SessionResume.ToolCall
expect(step1Call.id).toBe("c1")
expect(step1Call.name).toBe("read")
const step1Text = result.steps[2].parts[0] as SessionResume.TextPart
expect(step1Text.text).toBe("Done reading. Now editing.")
// Step 2 continues the new assistant step with the second tool round.
const step2Types = result.steps[2].parts.map((p) => p.type)
expect(step2Types).toEqual(["text", "tool_call", "tool_result"])
const step2Call = result.steps[2].parts[1] as SessionResume.ToolCall
expect(step2Call.id).toBe("c2")
expect(step2Call.name).toBe("edit")
const step2Text = result.steps[3].parts[0] as SessionResume.TextPart
expect(step2Text.text).toBe("All done.")
})
test("pairs multiple tool calls with results", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Do two things"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"c1","name":"read","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call","call_id":"c2","name":"edit","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"function_call_output","call_id":"c1","output":"r1"}}',
'{"type":"response_item","payload":{"id":"item_005","type":"function_call_output","call_id":"c2","output":"r2"}}',
'{"type":"response_item","payload":{"id":"item_006","type":"message","role":"assistant","content":[{"type":"output_text","text":"Both done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const calls = mid.parts.filter((p): p is SessionResume.ToolCall => p.type === "tool_call")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(calls).toHaveLength(2)
expect(results).toHaveLength(2)
expect(calls.map((c) => c.id).sort()).toEqual(["c1", "c2"])
expect(results.map((r) => r.callID).sort()).toEqual(["c1", "c2"])
})
test("converts unpaired Codex tool call to terminal error", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Go"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"orphan","name":"read","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"message","role":"user","content":[{"type":"input_text","text":"Next topic"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const errors = mid.parts.filter((p): p is SessionResume.ErrorPart => p.type === "error")
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("Unpaired tool call")
expect(errors[0].message).toContain("orphan")
})
test("handles orphan tool output (no matching call)", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Go"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call_output","call_id":"no_match","output":"orphan"}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
})
test("handles repeated call IDs across rounds", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"First"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"call_01","name":"read","arguments":"{\\"f\\":\\"a\\"}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"call_01","output":"result a"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done first."}]}}',
'{"type":"response_item","payload":{"id":"item_005","type":"message","role":"user","content":[{"type":"input_text","text":"Second"}]}}',
'{"type":"response_item","payload":{"id":"item_006","type":"function_call","call_id":"call_01","name":"read","arguments":"{\\"f\\":\\"b\\"}"}}',
'{"type":"response_item","payload":{"id":"item_007","type":"function_call_output","call_id":"call_01","output":"result b"}}',
'{"type":"response_item","payload":{"id":"item_008","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done second."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const toolSteps = result.steps.filter(
(s) => s.role === "assistant" && s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps).toHaveLength(2)
for (const step of toolSteps) {
const tr = step.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("call_01")
}
})
test("handles encrypted reasoning (empty summary)", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Think deeply"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"reasoning","summary":[]}}',
'{"type":"response_item","payload":{"id":"item_003","type":"message","role":"assistant","content":[{"type":"output_text","text":"Answer"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
expect(result.steps).toHaveLength(2)
})
test("counts empty assistant content as unsupported", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Test"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
expect(result.steps).toHaveLength(1) // only the user step
})
test("skips turn_context, event_msg, and world_state records", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
'{"type":"turn_context","payload":{}}',
'{"type":"event_msg","payload":{}}',
'{"type":"world_state","payload":{}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(2)
})
test("handles partial tool results within one round", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Go"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"ok","name":"ok","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call","call_id":"missing","name":"missing","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"function_call_output","call_id":"ok","output":"done"}}',
'{"type":"response_item","payload":{"id":"item_005","type":"message","role":"user","content":[{"type":"input_text","text":"Continuing."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
const errors = mid.parts.filter((p): p is SessionResume.ErrorPart => p.type === "error")
expect(results).toHaveLength(1)
expect(results[0].callID).toBe("ok")
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("missing")
})
test("handles custom_tool_call and custom_tool_call_output", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Run custom tool"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"custom_tool_call","call_id":"ct_01","name":"my-tool","input":{"key":"value"}}}',
'{"type":"response_item","payload":{"id":"item_003","type":"custom_tool_call_output","call_id":"ct_01","output":"custom result"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const tc = mid.parts.find((p): p is SessionResume.ToolCall => p.type === "tool_call")
expect(tc).toBeDefined()
expect(tc!.id).toBe("ct_01")
expect(tc!.name).toBe("my-tool")
expect(tc!.input).toEqual({ key: "value" })
const tr = mid.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("ct_01")
expect(tr!.content).toBe("custom result")
})
test("rejects empty input", () => {
expect(() => SessionResume.parseLines("")).toThrow(SessionResume.ParseError)
})
test("rejects malformed JSON", () => {
expect(() =>
SessionResume.parseLines(
'{"type":"session_meta","payload":{"cli_version":"0.8.0"}}\n{broken',
),
).toThrow(SessionResume.ParseError)
})
})
// ── Discovery ────────────────────────────────────────────────────
describe("SessionResume discovery", () => {
const id = "550e8400-e29b-41d4-a716-446655440000"
test("uses the Claude project slug and injected root", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-claude-"))
try {
const cwd = "/Users/test/project"
const directory = path.join(root, SessionResume.claudeProjectSlug(cwd))
fs.mkdirSync(directory, { recursive: true })
const expected = path.join(directory, `${id}.jsonl`)
fs.writeFileSync(expected, "{}")
expect(SessionResume.discoverClaude({ claude: root, cwd, id })).toEqual([expected])
expect(() => SessionResume.discoverClaude({ claude: root, cwd, id: "bad" })).toThrow(
SessionResume.ParseError,
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("lists only UUID Claude transcript names", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-claude-"))
try {
const cwd = "/Users/test/project"
const directory = path.join(root, SessionResume.claudeProjectSlug(cwd))
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(path.join(directory, "agent.jsonl"), "{}")
fs.writeFileSync(path.join(directory, "550e8400-e29b-41d4-a716-446655440000.jsonl"), "{}")
expect(SessionResume.discoverClaude({ claude: root, cwd }).map((file) => path.basename(file))).toEqual([
"550e8400-e29b-41d4-a716-446655440000.jsonl",
])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("scans Codex rollouts from an injected root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-codex-"))
try {
const directory = path.join(root, "2026", "08", "03")
fs.mkdirSync(directory, { recursive: true })
const expected = path.join(directory, `rollout-2026-08-03T10-00-00-${id}.jsonl`)
fs.writeFileSync(expected, '{"type":"session_meta","payload":{"cwd":"/repo"}}\n')
await expect(SessionResume.discoverCodex({ codex: root, cwd: "/repo", id })).resolves.toEqual([expected])
await expect(SessionResume.discoverCodex({ codex: root, cwd: "/repo", id: "bad" })).rejects.toThrow(
SessionResume.ParseError,
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("scans a Codex rollout stored at the sessions root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-codex-"))
try {
const expected = path.join(root, `rollout-2026-08-03T10-00-00-${id}.jsonl`)
fs.writeFileSync(expected, '{"type":"session_meta","payload":{"cwd":"/repo"}}\n')
await expect(SessionResume.discoverCodex({ codex: root, cwd: "/repo", id })).resolves.toEqual([expected])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("no-ID discovery matches cwd with backslash-separated paths (Windows)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-codex-"))
try {
const cwd = "C:\\Users\\dev\\project"
const file = path.join(root, `rollout-2026-08-03T10-00-00-${id}.jsonl`)
// Escaped backslashes in JSON are valid on all platforms
fs.writeFileSync(file, '{"type":"session_meta","payload":{"cwd":"C:\\\\Users\\\\dev\\\\project"}}\n')
await expect(SessionResume.discoverCodex({ codex: root, cwd })).resolves.toEqual([file])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
})
// ── Full file parsing ────────────────────────────────────────────
describe("parse file", () => {
test("parses claude.jsonl fixture with exact step count", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
expect(result.format).toBe("claude")
expect(result.version).toBe(2)
// 13 steps: user + 6 assistant tool rounds + interleaved text assistants
expect(result.steps.length).toBe(13)
// First step is user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts[0].type).toBe("text")
// Steps alternate between user and assistant
const roles = result.steps.map((s) => s.role)
expect(roles[0]).toBe("user")
// Has tool calls in at least 3 distinct steps
const toolSteps = result.steps.filter((s) =>
s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps.length).toBeGreaterThanOrEqual(3)
// Has reasoning
const reasoningSteps = result.steps.filter((s) =>
s.parts.some((p) => p.type === "reasoning"),
)
expect(reasoningSteps.length).toBeGreaterThanOrEqual(1)
// Unsported content: redacted_thinking + custom_block + server_tool_result in user msg_017
expect(result.unsupported).toBe(3)
})
test("parses codex.jsonl fixture with exact step count", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/codex.jsonl",
)
expect(result.format).toBe("codex")
expect(result.version).toBe(0)
expect(result.steps.length).toBe(14)
// First step is user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts[0].type).toBe("text")
// Has both user and assistant
const roleSet = new Set(result.steps.map((s) => s.role))
expect(roleSet.has("user")).toBe(true)
expect(roleSet.has("assistant")).toBe(true)
// Has tool calls in multiple steps (two distinct tool rounds)
const toolSteps = result.steps.filter((s) =>
s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps.length).toBeGreaterThanOrEqual(2)
// Has unsupported content from encrypted reasoning + empty assistant
expect(result.unsupported).toBeGreaterThanOrEqual(2)
})
test("codex fixture: first user produces two complete assistant tool rounds", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/codex.jsonl",
)
// Step 0: user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts[0].type).toBe("text")
// Step 1: first completed tool round.
expect(result.steps[1].role).toBe("assistant")
const step1Types = result.steps[1].parts.map((p) => p.type)
expect(step1Types).toContain("tool_call")
expect(step1Types).toContain("tool_result")
const step1Call = result.steps[1].parts.find(
(p): p is SessionResume.ToolCall => p.type === "tool_call",
)
expect(step1Call).toBeDefined()
expect(step1Call!.name).toBe("read")
// Step 2 starts the next assistant content and tool round.
expect(result.steps[2].role).toBe("assistant")
const step2Calls = result.steps[2].parts.filter(
(p): p is SessionResume.ToolCall => p.type === "tool_call",
)
expect(step2Calls.length).toBe(2)
const step2Results = result.steps[2].parts.filter(
(p): p is SessionResume.ToolResult => p.type === "tool_result",
)
expect(step2Results.length).toBe(2)
const step2Texts = result.steps[2].parts.filter(
(p): p is SessionResume.TextPart => p.type === "text",
)
expect(step2Texts.length).toBeGreaterThanOrEqual(1)
})
test("claude fixture: consecutive assistants before tool result merged into one step", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
// msg_018 (redacted_thinking), msg_019 (custom_block), msg_020 (tool_use)
// should be merged into a single assistant step
const lastAssistant = result.steps[result.steps.length - 1]
expect(lastAssistant.role).toBe("assistant")
const partTypes = lastAssistant.parts.map((p) => p.type)
// Should contain unsupported, unsupported, tool_call, error (from markUnpaired)
expect(partTypes.filter((t) => t === "unsupported").length).toBe(2)
expect(partTypes.filter((t) => t === "tool_call").length).toBe(1)
expect(partTypes.filter((t) => t === "error").length).toBe(1)
})
test("claude fixture: sidechain and metadata records excluded", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
// The fixture has 22 lines total, but 2 are metadata/sidechain records
// (ai-title line 7, last-prompt line 14)
// Verify no step contains sidechain text
for (const step of result.steps) {
for (const part of step.parts) {
if (part.type === "text") {
expect(part.text).not.toContain("Session title")
}
}
}
})
test("claude fixture: repeated IDs across rounds handled correctly", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
// toolu_01 appears in two different rounds (msg_002 and msg_014)
const toolSteps = result.steps.filter(
(s) =>
s.role === "assistant" &&
s.parts.some((p) => p.type === "tool_call" && p.id === "toolu_01"),
)
expect(toolSteps.length).toBe(2)
})
test("codex fixture: repeated call IDs across rounds", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/codex.jsonl",
)
// call_01 appears in two different rounds
const call01Steps = result.steps.filter(
(s) =>
s.role === "assistant" &&
s.parts.some((p) => p.type === "tool_call" && p.id === "call_01"),
)
expect(call01Steps.length).toBe(2)
})
test("codex fixture: missing session_meta is rejected", () => {
const text = [
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("rejects missing file", async () => {
await expect(SessionResume.parse("/nonexistent/file.jsonl")).rejects.toThrow()
})
})
// ── Unrecognized format rejection ────────────────────────────────
describe("rejection", () => {
test("rejects unrecognized format", () => {
expect(() => SessionResume.parseLines('[1,2,3]')).toThrow(SessionResume.ParseError)
expect(() => SessionResume.parseLines('{"key":"value"}')).toThrow(SessionResume.ParseError)
})
})
@@ -727,6 +727,50 @@ describe("RemoteCommand", () => {
// Even when the catalog advertises "compact" (the synthesized built-in),
// a name not in the catalog must still be rejected.
test("resume commands appear from the current catalog", () => {
const catalog = RemoteCommand.build([
{
name: "resume-claude",
description: "import a Claude Code session transcript",
source: "command",
hints: ["$ARGUMENTS"],
template: "must-not-leak",
},
{
name: "resume-codex",
description: "import an OpenAI Codex session transcript",
source: "command",
hints: ["$ARGUMENTS"],
template: "must-not-leak",
},
])
const names = catalog.commands.map((item) => item.name)
expect(names).toContain("resume-claude")
expect(names).toContain("resume-codex")
expect(names).toContain("compact")
expect(JSON.stringify(catalog)).not.toContain("template")
expect(JSON.stringify(catalog)).not.toContain("must-not-leak")
expect(catalog.commands.find((item) => item.name === "resume-claude")?.source).toBe("command")
expect(catalog.commands.find((item) => item.name === "resume-codex")?.source).toBe("command")
})
test("resume commands disappear from a catalog without them", () => {
const catalog = RemoteCommand.build([
{
name: "review",
description: "Review changes",
source: "command",
hints: ["$ARGUMENTS"],
template: "must-not-leak",
},
])
const names = catalog.commands.map((item) => item.name)
expect(names).not.toContain("resume-claude")
expect(names).not.toContain("resume-codex")
})
test("execute rejects arbitrary names even when the catalog advertises built-in compact", async () => {
const calls: unknown[] = []
const remote = RemoteCommand.create({