diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts index 38641043b70..440fad3a3fc 100644 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -137,19 +137,6 @@ async function runPinnedTranscriptPasses( } } -export async function proveClaudeTranscriptBranch( - input: BranchProofInput & { transcriptPath: string } -): Promise { - return runPinnedTranscriptPasses(input.transcriptPath, undefined, async (readLines) => { - const builder = createBranchProof(input) - let index = 0 - for await (const record of readLines()) { - builder.add(record.line, index++, record.terminated) - } - return builder.finish() - }) -} - /** * Prove the branch from the file's last transcript row back to the anchor, then * replay the anchor..tail records off the SAME pinned bytes. Two bounded passes diff --git a/src/main/claude/claude-transcript-branch-streaming.test.ts b/src/main/claude/claude-transcript-branch-streaming.test.ts index 2fa2c1349bd..ce283ce501d 100644 --- a/src/main/claude/claude-transcript-branch-streaming.test.ts +++ b/src/main/claude/claude-transcript-branch-streaming.test.ts @@ -73,28 +73,35 @@ import { appendFile, mkdtemp, rename, rm, truncate, unlink, writeFile } from 'no import { ClaudeTranscriptPreviousCursorMissingError, ClaudeTranscriptTailIncompleteError, - proveClaudeTranscriptBranch, - proveClaudeTranscriptBranchFromJsonl + replayClaudeTranscriptBranchAncestry, + replayClaudeTranscriptBranchAncestryFromJsonl } from './claude-transcript-branch-proof' const row = (uuid: string, parentUuid: string | null, extra = {}) => `${JSON.stringify({ type: 'user', uuid, parentUuid, sessionId: 'provider', ...extra })}\n` const marker = (leafUuid: string, sessionId = 'provider') => `${JSON.stringify({ type: 'last-prompt', leafUuid, sessionId })}\n` +/** Carries no uuid, so it can be neither a tip nor a marker. */ +const SUMMARY = `${JSON.stringify({ type: 'summary', summary: 'title' })}\n` const ROOT = row('root', null) const CHILD = row('child', 'root') const SOURCE = ROOT + marker('root') let directory = '' -const read = (previousLeafUuid: string | null = null, intentionalRewindUuid?: string) => - proveClaudeTranscriptBranch({ +let replayed: string[] = [] +// The live caller always anchors the replay at the cursor it already holds. +const replay = (anchorUuid = 'root') => + replayClaudeTranscriptBranchAncestry({ transcriptPath: state.path, providerSessionId: 'provider', - previousLeafUuid, - ...(intentionalRewindUuid === undefined ? {} : { intentionalRewindUuid }) + previousLeafUuid: anchorUuid, + ancestryAnchorUuid: anchorUuid, + onAncestorRecord: (_record, uuid) => replayed.push(uuid) }) +const read = async (anchorUuid?: string) => (await replay(anchorUuid)).proof beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-branch-streaming-')) + replayed = [] Object.assign(state, { path: join(directory, 'transcript.jsonl'), opens: 0, @@ -121,7 +128,7 @@ afterEach(async () => { } }) -it('decodes UTF-8 across chunks and proves old ancestry beyond large message bodies', async () => { +it('decodes UTF-8 across chunks and replays ancestry beyond large message bodies', async () => { const prefix = JSON.stringify({ type: 'comment', content: '' }).indexOf('"content":"') + 11 const comment = `${JSON.stringify({ type: 'comment', content: `${'x'.repeat(65535 - prefix)}🙂é漢字` })}\n` const contents = @@ -130,13 +137,18 @@ it('decodes UTF-8 across chunks and proves old ancestry beyond large message bod CHILD + marker('child').trimEnd() await writeFile(state.path, contents) - expect(await read('root')).toEqual( - proveClaudeTranscriptBranchFromJsonl({ + const fromString: string[] = [] + expect(await replay()).toEqual( + replayClaudeTranscriptBranchAncestryFromJsonl({ contents, providerSessionId: 'provider', - previousLeafUuid: 'root' + previousLeafUuid: 'root', + ancestryAnchorUuid: 'root', + onAncestorRecord: (_record, uuid) => fromString.push(uuid) }) ) + expect(replayed).toEqual(fromString) + expect(replayed).toEqual(['child']) }) it.each([ @@ -156,31 +168,36 @@ it.each([ it('proves only the original finite prefix while the same file grows', async () => { state.afterStat = () => appendFile(state.path, CHILD + marker('child') + ' '.repeat(1024 * 1024)) - expect(await read()).toEqual({ leafUuid: 'root', relation: 'initial' }) + expect(await read()).toEqual({ leafUuid: 'root', relation: 'same' }) expect(state.bytesRead).toBe(Buffer.byteLength(SOURCE)) }) -it('keeps a proved rewind tied to the original prefix', async () => { - await writeFile(state.path, ROOT + CHILD + marker('root')) - state.afterStat = () => appendFile(state.path, marker('child')) - expect(await read('child', 'root')).toEqual({ leafUuid: 'root', relation: 'intentional-rewind' }) +it('replays the ancestry off the same pinned prefix the proof read', async () => { + const contents = ROOT + CHILD + await writeFile(state.path, contents) + state.afterStat = () => appendFile(state.path, row('grandchild', 'child')) + expect(await replay()).toEqual({ + proof: { leafUuid: 'child', relation: 'descendant' }, + chain: ['child'] + }) + expect(replayed).toEqual(['child']) + expect(state.bytesRead).toBe(Buffer.byteLength(contents) * 2) }) it.each([ - ['empty', '', SOURCE, null], - ['missing marker', ROOT, marker('root'), null], + ['empty', '', SOURCE, 'root'], ['missing previous cursor', SOURCE, CHILD + marker('child'), 'child'] ] as const)( 'finishes a growing %s proof on the same open handle', - async (_name, contents, growth, previous) => { + async (_name, contents, growth, anchor) => { await writeFile(state.path, contents) state.afterStat = () => appendFile(state.path, growth) - expect((await read(previous)).leafUuid).toBe(previous ?? 'root') + expect((await read(anchor)).leafUuid).toBe(anchor) expect(state.opens).toBe(1) } ) -it.each(['', ROOT])('keeps a static missing marker fatal', async (contents) => { +it.each(['', SUMMARY])('keeps a static missing tip fatal', async (contents) => { await writeFile(state.path, contents) await expect(read()).rejects.toThrow('missing last-prompt marker') }) @@ -190,22 +207,22 @@ it('preserves the typed static missing-cursor error for existing root reproof', }) it.each([ - ['conflict', ROOT + row('root', 'foreign') + marker('root'), 'conflicting ancestry'], - ['wrong session', ROOT + marker('root', 'foreign'), 'invalid last-prompt'], - ['append order', CHILD + ROOT + marker('child'), 'parent row follows'], - ['missing ancestor', CHILD + marker('child'), 'missing ancestor'] + ['conflict', ROOT + row('root', 'foreign') + marker('root'), 'root', 'conflicting ancestry'], + ['wrong session', ROOT + marker('root', 'foreign'), 'root', 'invalid last-prompt'], + ['append order', CHILD + ROOT, 'root', 'parent row follows'], + ['missing ancestor', CHILD, 'child', 'missing ancestor'] ] as const)( 'does not turn %s into a retry when the file grows', - async (_name, contents, message) => { + async (_name, contents, anchor, message) => { await writeFile(state.path, contents) state.afterStat = () => appendFile(state.path, CHILD) - await expect(read()).rejects.toThrow(message) + await expect(read(anchor)).rejects.toThrow(message) } ) it('does not infer growth from a failed second stat', async () => { - await writeFile(state.path, ROOT) - state.afterStat = () => appendFile(state.path, marker('root')) + await writeFile(state.path, '') + state.afterStat = () => appendFile(state.path, SOURCE) state.statErrorOn = 2 await expect(read()).rejects.toThrow('missing last-prompt marker') }) @@ -219,7 +236,7 @@ it('keeps reading the original handle after pathname replacement', async () => { }) it('does not use a replacement file to establish growth', async () => { - await writeFile(state.path, ROOT) + await writeFile(state.path, '') state.afterStat = async () => { await rename(state.path, `${state.path}.original`) await writeFile(state.path, ROOT + CHILD + marker('child')) @@ -252,16 +269,18 @@ it('completes a record appended after the first observed extent without caller r }) it('validates the entire refreshed prefix instead of accepting a malformed repair', async () => { - await writeFile(state.path, ROOT) - state.afterStat = () => appendFile(state.path, row('root', 'foreign') + marker('root')) + const conflicting = row('root', 'foreign') + await writeFile(state.path, ROOT + conflicting.slice(0, -5)) + state.afterStat = () => appendFile(state.path, conflicting.slice(-5)) await expect(read()).rejects.toThrow('conflicting ancestry') expect(state.opens).toBe(1) }) it('keeps an unfinished growing repair retryable after one internal refresh', async () => { - await writeFile(state.path, ROOT) - state.afterStat = () => appendFile(state.path, ROOT) + const torn = ROOT.slice(0, -5) + await writeFile(state.path, torn) + state.afterStat = () => appendFile(state.path, ' ') await expect(read()).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError) expect(state.opens).toBe(1) - expect(state.bytesRead).toBe(Buffer.byteLength(ROOT) * 3) + expect(state.bytesRead).toBe(Buffer.byteLength(torn) * 2 + 1) }) diff --git a/src/main/claude/claude-transcript-marker-branch-proof.test.ts b/src/main/claude/claude-transcript-marker-branch-proof.test.ts new file mode 100644 index 00000000000..befb320b9e5 --- /dev/null +++ b/src/main/claude/claude-transcript-marker-branch-proof.test.ts @@ -0,0 +1,342 @@ +// The branch rules a Claude transcript must satisfy, read off the marker tip. The live replay +// proves from the file tail instead and falls back to these rules when no tail row qualifies. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { + ClaudeTranscriptTailIncompleteError, + proveClaudeTranscriptBranchFromJsonl +} from './claude-transcript-branch-proof' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + return root +} + +async function proveMarkerLeafUuid( + transcriptPath: string, + providerSessionId: string, + previousLeafUuid: string | null = null +): Promise { + const contents = await readFile(transcriptPath, 'utf8') + return proveClaudeTranscriptBranchFromJsonl({ contents, providerSessionId, previousLeafUuid }) + .leafUuid +} + +describe('Claude transcript marker branch proof', () => { + it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'leaf-current', + parentUuid: 'leaf-old', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe( + 'leaf-current' + ) + }) + + it('fails closed when a Claude transcript has no branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + '{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n', + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'missing last-prompt marker' + ) + }) + + it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-') + const transcript = join(root, 'session.jsonl') + await writeFile(transcript, '{"type":"last-prompt"', 'utf8') + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + + await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8') + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + }) + + it('refuses a Claude marker on a sibling branch', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sibling-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow( + 'sibling branch' + ) + }) + + it('refuses missing and cyclic Claude parent chains', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-') + const missing = join(root, 'missing.jsonl') + const cycle = join(root, 'cycle.jsonl') + await writeFile( + missing, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + await writeFile( + cycle, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' }, + { type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow( + 'missing ancestor absent' + ) + await expect(proveMarkerLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow( + 'cycle in parentUuid ancestry' + ) + }) + + it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' }, + { + type: 'system', + subtype: 'init', + uuid: 'init-frame', + parentUuid: null, + sessionId: 'session-1' + }, + { type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'marker leaf is missing from the session graph' + ) + }) + + it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'sidechain-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + proveMarkerLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { + type: 'assistant', + uuid: 'latest-after-sidechain', + parentUuid: 'main-after-sidechain', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + proveMarkerLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a post-snapshot descendant whose parent row was observed later', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { + type: 'assistant', + uuid: 'descendant', + parentUuid: 'previous', + sessionId: 'session-1' + }, + { type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(proveMarkerLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow( + 'parent row follows descendant' + ) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 13673626ebc..259f552dd98 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -5,21 +5,32 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionJournalIdentity, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type * as TranscriptLineDecoders from '../transcript-line-decoders' import { createLegacyIdentityTracker } from './journal-legacy-identity' -import { - appendLegacyTranscriptMessages, - importLegacyTranscriptIntoJournal -} from './journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from './journal-legacy-import' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' +// No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. +const decodedClaudeOverride = vi.hoisted(() => ({ message: null as NativeChatMessage | null })) + +vi.mock('../transcript-line-decoders', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + decodeClaudeTranscriptLine: (...args: Parameters) => + decodedClaudeOverride.message ?? actual.decodeClaudeTranscriptLine(...args) + } +}) + const CLAUDE_SESSION = '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88' const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' @@ -189,6 +200,7 @@ beforeEach(async () => { }) afterEach(async () => { + decodedClaudeOverride.message = null await rm(root, { recursive: true, force: true }) }) @@ -297,46 +309,6 @@ describe('claude import', () => { }) describe('codex import', () => { - it('upserts live transcript messages without rolling the structured epoch', async () => { - const journal = await open('codex', CODEX_SESSION) - const epoch = journal.epoch - const message = { - id: 'live-tui-message', - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: 'first version' }], - timestamp: 1_800_000_000_000, - source: 'transcript' as const - } - - await appendLegacyTranscriptMessages({ - journal, - agent: 'codex', - sessionId: CODEX_SESSION, - fence: 2, - messages: [message] - }) - await appendLegacyTranscriptMessages({ - journal, - agent: 'codex', - sessionId: CODEX_SESSION, - fence: 2, - messages: [{ ...message, blocks: [{ type: 'text', text: 'final version' }] }] - }) - - expect(journal.epoch).toBe(epoch) - expect(journal.snapshot().items).toMatchObject([ - { - itemId: legacyKey('live-tui-message'), - revision: 2, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'final version' }] - } - } - ]) - }) - it('keys rollout records in the import-scoped namespace, not as app-server ordinals', async () => { const filePath = await writeFixture('rollout.jsonl', CODEX_LINES) const journal = await open('codex', CODEX_SESSION) @@ -396,6 +368,32 @@ describe('codex import', () => { }) }) +type RosterAgents = Extract< + NativeChatMessage['blocks'][number], + { type: 'subagent-group' } +>['agents'] + +async function importDecodedRoster(agents: RosterAgents): Promise { + decodedClaudeOverride.message = { + id: 'legacy-roster', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }] + } + const filePath = await writeFixture('claude-roster.jsonl', [CLAUDE_LINES[2]]) + const journal = await open('claude', CLAUDE_SESSION) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath } + }) + expect(result).toMatchObject({ ok: true, imported: 1 }) + return journal +} + describe('payload bounds on import', () => { it('marks a clipped tool result and discards the remainder', async () => { const output = 'y'.repeat(64 * 1024) @@ -436,32 +434,13 @@ describe('payload bounds on import', () => { it('bounds an imported subagent roster by entry count, label and id', async () => { // The import reads an untrusted file: nothing upstream bounded either string. const oversized = 'z'.repeat(20 * 1024) - const journal = await open('claude', CLAUDE_SESSION) - await appendLegacyTranscriptMessages({ - journal, - agent: 'claude', - sessionId: CLAUDE_SESSION, - fence: 1, - messages: [ - { - id: 'legacy-roster', - role: 'assistant', - timestamp: null, - source: 'transcript', - blocks: [ - { - type: 'subagent-group', - groupId: 'group-1', - agents: Array.from({ length: 80 }, (_, index) => ({ - id: index === 0 ? oversized : `task-${index}`, - label: index === 0 ? oversized : `label-${index}`, - state: 'working' as const - })) - } - ] - } - ] - }) + const journal = await importDecodedRoster( + Array.from({ length: 80 }, (_, index) => ({ + id: index === 0 ? oversized : `task-${index}`, + label: index === 0 ? oversized : `label-${index}`, + state: 'working' as const + })) + ) const body = journal.snapshot().items[0]?.body const block = body?.kind === 'message' ? body.blocks[0] : undefined @@ -478,31 +457,10 @@ describe('payload bounds on import', () => { // The id is the roster key: it takes the same bounded-id format the wires // use, so a later wire bound is a no-op instead of a second, merging clip. const head = 'y'.repeat(512) - const journal = await open('claude', CLAUDE_SESSION) - await appendLegacyTranscriptMessages({ - journal, - agent: 'claude', - sessionId: CLAUDE_SESSION, - fence: 1, - messages: [ - { - id: 'legacy-roster-collision', - role: 'assistant', - timestamp: null, - source: 'transcript', - blocks: [ - { - type: 'subagent-group', - groupId: 'group-1', - agents: [ - { id: `${head}-one`, label: 'Audit', state: 'working' as const }, - { id: `${head}-two`, label: 'Audit', state: 'working' as const } - ] - } - ] - } - ] - }) + const journal = await importDecodedRoster([ + { id: `${head}-one`, label: 'Audit', state: 'working' as const }, + { id: `${head}-two`, label: 'Audit', state: 'working' as const } + ]) const body = journal.snapshot().items[0]?.body const block = body?.kind === 'message' ? body.blocks[0] : undefined diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index ed509190038..cf9ff45805c 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -62,30 +62,6 @@ export type LegacyImportResult = } | { ok: false; error: string } -export async function appendLegacyTranscriptMessages(input: { - journal: AgentSessionJournal - agent: AgentType - sessionId: string - fence: number - messages: NativeChatMessage[] -}): Promise { - let appended = 0 - for (const message of input.messages) { - await input.journal.appendItem( - { - provider: 'legacy', - agent: input.agent, - sessionId: input.sessionId, - recordId: message.id - }, - legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS), - { fence: input.fence, observedAt: message.timestamp ?? undefined } - ) - appended += 1 - } - return appended -} - export async function importLegacyTranscriptIntoJournal(input: { journal: AgentSessionJournal agent: AgentType diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 1ef945b1ea2..4865df90af9 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -3,10 +3,6 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { - ClaudeTranscriptTailIncompleteError, - proveClaudeTranscriptBranch -} from '../claude/claude-transcript-branch-proof' import { resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -16,16 +12,6 @@ afterEach(async () => { tempRoots = [] }) -async function readClaudeTranscriptLeafUuid( - transcriptPath: string, - providerSessionId: string, - previousLeafUuid: string | null = null -): Promise { - return ( - await proveClaudeTranscriptBranch({ transcriptPath, providerSessionId, previousLeafUuid }) - ).leafUuid -} - async function makeRoot(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)) tempRoots.push(root) @@ -41,311 +27,6 @@ function restoreEnv(key: string, previous: string | undefined): void { } describe('resolveSessionFilePath', () => { - it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-leaf-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'leaf-current', - parentUuid: 'leaf-old', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe( - 'leaf-current' - ) - }) - - it('fails closed when a Claude transcript has no branch marker', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - '{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n', - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'missing last-prompt marker' - ) - }) - - it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-') - const transcript = join(root, 'session.jsonl') - await writeFile(transcript, '{"type":"last-prompt"', 'utf8') - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf( - ClaudeTranscriptTailIncompleteError - ) - - await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8') - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf( - ClaudeTranscriptTailIncompleteError - ) - }) - - it('refuses a Claude marker on a sibling branch', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-sibling-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, - { type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' }, - { type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow( - 'sibling branch' - ) - }) - - it('refuses missing and cyclic Claude parent chains', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-') - const missing = join(root, 'missing.jsonl') - const cycle = join(root, 'cycle.jsonl') - await writeFile( - missing, - [ - { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, - { type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - await writeFile( - cycle, - [ - { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, - { type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' }, - { type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow( - 'missing ancestor absent' - ) - await expect(readClaudeTranscriptLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow( - 'cycle in parentUuid ancestry' - ) - }) - - it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'sidechain-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - isSidechain: true - }, - { type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' }, - { - type: 'system', - subtype: 'init', - uuid: 'init-frame', - parentUuid: null, - sessionId: 'session-1' - }, - { type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'marker leaf is missing from the session graph' - ) - }) - - it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-') - const transcript = join(root, 'session.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'sidechain-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - isSidechain: true - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'sidechain-assistant', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'not on the main transcript' - ) - }) - - it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( - 'not on the main transcript' - ) - }) - - it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect( - readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') - ).rejects.toThrow('not on the main transcript') - }) - - it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { - type: 'assistant', - uuid: 'latest-after-sidechain', - parentUuid: 'main-after-sidechain', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect( - readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') - ).rejects.toThrow('not on the main transcript') - }) - - it('rejects a post-snapshot descendant whose parent row was observed later', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { - type: 'assistant', - uuid: 'descendant', - parentUuid: 'previous', - sessionId: 'session-1' - }, - { type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' }, - { type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow( - 'parent row follows descendant' - ) - }) - it('globs Claude project subdirs for .jsonl', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-') const claudeProjectsDir = join(root, 'claude-projects') diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2c965b932d5..3f8adae4906 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -6,7 +6,7 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,7 +31,7 @@ import type { import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { appendLegacyTranscriptMessages } from '../native-chat/agent-session-journal/journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { OrcaRuntimeService } from './orca-runtime' @@ -407,20 +407,21 @@ describe('a structured codex session over agentSession.*', () => { identity, journalDir: journalDirectoryFor(root, identity) }) - await appendLegacyTranscriptMessages({ + const rollout = join(root, 'legacy-rollout.jsonl') + await writeFile( + rollout, + `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'legacy question', kind: 'plain' } + })}\n` + ) + await importLegacyTranscriptIntoJournal({ journal, agent: 'codex', sessionId: THREAD, fence: 0, - messages: [ - { - id: 'legacy-user-1', - role: 'user', - source: 'transcript', - timestamp: 1_800_000_000_000, - blocks: [{ type: 'text', text: 'legacy question' }] - } - ] + options: { filePath: rollout } }) const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>(