From 6059a5f7fa69fee76eb0c3cdc18cf1edaa8e0bd5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:27:01 -0700 Subject: [PATCH] Native chat: a message is never marked not delivered after a crash, and reopening a chat stops reading the transcript (#30580) * Native chat: never mark a message not delivered after a crash; reopening a chat stops reading the transcript After a crash or restart, attach read a window of the agent's saved log and marked any unconfirmed message missing from it as rejected "not delivered". Absence from a log Orca does not own is not proof: the window's anchor moves every turn (STA-7416) and its text filters drop tagged prompts (STA-7453), so messages the agent received were marked undelivered and retyping them duplicated the question. The read also ran on every attach (STA-7434). Delete the inference: the provider history window and its adapter hooks (Claude window and branch proof, OpenCode stored-messages reader), the submission matcher, and the attach step. A send the crash boundary leaves unconfirmed stays an ordinary sent message; the interrupted turn still ends through the existing settlement. Explicit live refusals are unchanged. A queued card whose hand-off a restart left unconfirmed no longer counts as able to return to the queue, so it stops keeping an idle chat loaded. * test(native-chat): update restart fixture calls after history removal * Hide rewind targets without a saved native boundary * Run rewind boundary regression across host and renderer * Keep incoming main package-manager lock unchanged * Test recovered chat consumer outcomes without delivery inference * Assign the complete cursor in recovered phone regression fixture * Supply presentation capture in rewind boundary fixture --- ...le-session-terminal-create-actions.test.ts | 50 +++ ...ile-structured-agent-session-send.test.tsx | 47 ++- src/main/acp/acp-launch-specs.ts | 5 - src/main/acp/acp-recovery-history.test.ts | 224 ------------ src/main/acp/acp-recovery-history.ts | 132 ------- .../acp-structured-session-adapter-deps.ts | 5 +- .../acp/acp-structured-session-adapter.ts | 5 - ...laude-history-window-source-budget.test.ts | 233 ------------- .../claude-structured-history-window.test.ts | 322 ----------------- .../claude-structured-history-window.ts | 251 ------------- .../claude-structured-session-adapter.ts | 13 - .../claude-transcript-branch-graph.test.ts | 69 ---- .../claude/claude-transcript-branch-graph.ts | 253 -------------- .../claude/claude-transcript-branch-proof.ts | 169 --------- ...claude-transcript-branch-streaming.test.ts | 284 --------------- ...ude-transcript-marker-branch-proof.test.ts | 303 ---------------- .../journal-crash-boundary.spec.ts | 287 +-------------- .../journal-recovery-contracts.test.ts | 2 - .../journal-restart-reconciliation.spec.ts | 330 ------------------ .../journal-restart-reconciliation.ts | 121 ------- .../journal-submission-reconciler.ts | 173 --------- ...-agent-session-accept-then-deliver.test.ts | 4 - ...ed-agent-session-acquire-isolation.test.ts | 58 ++- ...structured-agent-session-adapter-router.ts | 12 +- .../structured-agent-session-adapter.ts | 6 - .../structured-agent-session-attach-flow.ts | 18 +- ...gent-session-attach-reconciliation.test.ts | 187 ---------- .../structured-agent-session-attach.ts | 76 +--- ...uctured-agent-session-conversation-open.ts | 15 +- ...ured-agent-session-create-adoption.test.ts | 7 - ...d-agent-session-provider-history-window.ts | 26 -- ...session-queued-message-rig.test-fixture.ts | 4 +- ...agent-session-recovered-queue-idle.test.ts | 75 ++++ ...ssion-restart-interruption-test-harness.ts | 16 +- ...t-session-restart-offer-withdrawal.test.ts | 2 +- ...ed-agent-session-restart-ownership.test.ts | 18 +- ...d-agent-session-restart-undrivable.test.ts | 3 - .../structured-rewind-cut.ts | 36 +- .../opencode-acp-stored-messages.test.ts | 111 ------ .../opencode/opencode-acp-stored-messages.ts | 38 -- ...gent-session-restart-dismiss-fence.test.ts | 7 +- .../structured-agent-runtime-registrations.ts | 1 - ...ructured-chat-worker-start-unknown.test.ts | 76 ++++ .../launch/mobile-terminal-chat-fork.test.ts | 6 + .../use-native-chat-rewind-host.ts | 17 +- .../native-chat/use-native-chat-rewind.ts | 28 +- src/shared/agent-session-rewind.ts | 33 +- src/shared/agent-session-wire.ts | 2 +- ...ctured-agent-session-dispatch-rejection.ts | 4 +- .../native-chat-rewind-boundary.unit.test.ts | 103 ++++++ 50 files changed, 539 insertions(+), 3728 deletions(-) delete mode 100644 src/main/acp/acp-recovery-history.test.ts delete mode 100644 src/main/acp/acp-recovery-history.ts delete mode 100644 src/main/claude/claude-history-window-source-budget.test.ts delete mode 100644 src/main/claude/claude-structured-history-window.test.ts delete mode 100644 src/main/claude/claude-structured-history-window.ts delete mode 100644 src/main/claude/claude-transcript-branch-graph.test.ts delete mode 100644 src/main/claude/claude-transcript-branch-graph.ts delete mode 100644 src/main/claude/claude-transcript-branch-proof.ts delete mode 100644 src/main/claude/claude-transcript-branch-streaming.test.ts delete mode 100644 src/main/claude/claude-transcript-marker-branch-proof.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-recovered-queue-idle.test.ts delete mode 100644 src/main/opencode/opencode-acp-stored-messages.test.ts delete mode 100644 src/main/opencode/opencode-acp-stored-messages.ts create mode 100644 src/main/runtime/structured-chat-worker-start-unknown.test.ts create mode 100644 tests/e2e/native-chat-rewind-boundary.unit.test.ts diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts index 36ce36186a5..fdb3e6a29db 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts @@ -412,6 +412,56 @@ describe('phone chat CLI fork creation owner', () => { expect(resolution?.isTabChatView('ordinary-tab')).toBe(true) }) + it('reports the strict saved-conversation refusal without opening a terminal or fallback', async () => { + const scope = createScope( + clientReturning({ + ok: false, + error: { code: 'chat_fork_source_unavailable', message: 'chat_fork_source_unavailable' } + }) + ) + scope.hostCapabilities = [SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY] + scope.sessionTabsRef.current = [ + { + type: 'agent-session', + id: 'chat-tab', + title: 'Chat', + sessionId: 'chat-1', + agent: 'claude', + isActive: true + } + ] + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + expect( + await actions?.handleCreateTerminal(undefined, { + sourceChatFork: { sessionId: 'chat-1', agent: 'claude' } + }) + ).toBe('reported-failure') + }) + expect(scope.client.sendRequest).toHaveBeenCalledTimes(1) + expect(scope.client.sendRequest).toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.objectContaining({ + sourceChatFork: { sessionId: 'chat-1' } + }), + { timeoutMs: 120_000 } + ) + expect(scope.setActiveSessionTabId).not.toHaveBeenCalled() + expect(scope.setActiveHandle).not.toHaveBeenCalled() + expect(scope.setSessionTabs).not.toHaveBeenCalled() + expect(scope.nativeChatController.setTabView).not.toHaveBeenCalled() + expect(scope.setCreateError).toHaveBeenCalledWith( + "Could not open Claude in a new CLI. Check that the chat has a saved conversation and review the agent's command and settings on the host, then try again." + ) + }) + it('reports a busy create before sending the chat intent', async () => { const scope = createScope(clientReturning()) scope.hostCapabilities = [SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY] diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 5f1535fb949..cc3b412ae81 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -15,6 +15,7 @@ import { type StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices' import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import { useMobileNativeChatSendError, @@ -270,16 +271,17 @@ describe('mobile structured send actions', () => { } ) - it('keeps the original unknown host row while allowing another Send', async () => { + it('shows a recovered unknown once and a new Send and reply without replay', async () => { const original = structuredSendResultFixture('unknown') if (!('submission' in original)) { throw new Error('expected a submission answer') } + original.submission.recovered = true const event = snapshotEvent() event.page.submissions = [original.submission] event.page.items = [ { - itemId: 'original-message', + itemId: agentJournalSubmissionKey(original.submission.clientMessageId), revision: 1, sequence: 1, observedAt: 10, @@ -293,6 +295,10 @@ describe('mobile structured send actions', () => { act(() => listener?.(event)) const messages = hook!.session.messages expect(messages).toHaveLength(1) + expect(messages[0]?.unsent).toBeUndefined() + expect(hook!.isWorking).toBe(false) + expect(mobileNativeChatUnsentNotices(event.page, agent).size).toBe(0) + expect(renderer!.root.findByType('span').children).toEqual([]) await act(async () => { expect(await hook!.sendWithOutcome('same text')).toBe('accepted') }) @@ -300,7 +306,42 @@ describe('mobile structured send actions', () => { const sent = agentJournalSubmissionKey(sentIds()[0] ?? '') expect(hook!.session.messages.map((message) => message.id)).toEqual([messages[0]?.id, sent]) expect(hook!.session.messages[0]).toEqual(messages[0]) - expect(event.page.submissions[0]?.dispatchState).toBe('unknown') + const followUp = structuredSendResultFixture('accepted') + if (!('submission' in followUp)) { + throw new Error('expected a submission answer') + } + const newId = sentIds()[0]! + expect(newId).not.toBe(original.submission.clientMessageId) + followUp.submission.clientMessageId = newId + const reply = snapshotEvent() + reply.page.submissions = [original.submission, followUp.submission] + reply.page.items = [ + ...event.page.items, + { ...event.page.items[0]!, itemId: sent, sequence: 2, observedAt: 20 }, + { + itemId: 'assistant-reply', + revision: 1, + sequence: 3, + observedAt: 30, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Reply to the new send' }] + } + } + ] + reply.page.liveCursor = { epoch: 'epoch-1', sequence: 3 } + act(() => listener?.(reply)) + expect(hook!.session.messages.map((message) => message.id)).toEqual([ + messages[0]?.id, + sent, + 'assistant-reply' + ]) + expect(hook!.session.messages.at(-1)?.blocks).toEqual([ + { type: 'text', text: 'Reply to the new send' } + ]) + expect(hook!.session.messages[0]).toEqual(messages[0]) + expect(reply.page.submissions[0]).toMatchObject({ dispatchState: 'unknown', recovered: true }) expect(calls()).toHaveLength(1) }) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index ff6ab0546de..281751cbd00 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -7,8 +7,6 @@ import { ANTIGRAVITY_ACP_DIALECT } from './acp-dialects/antigravity-dialect' import { directoryAccountBinding, type AcpAccountBinding } from './acp-account-binding' import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' -import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-stored-messages' -import type { AcpStoredUserMessagesReader } from './acp-recovery-history' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' @@ -127,8 +125,6 @@ export type AcpLaunchSpec = { compaction?: true /** Rejects copy arguments that route the CLI away from the copy's store. */ assertCopyLaunchArgs?: (args: readonly string[]) => void - /** Used only for restart recovery. */ - readStoredUserMessages?: AcpStoredUserMessagesReader modelDiscovery: AcpModelDiscovery visualsSkill?: AcpVisualsSkillLoader transcriptImport: StructuredAgentTranscriptImport | null @@ -191,7 +187,6 @@ function openCodeLaunchSpec( loginCommand: agentSessionSignInFor(row.agent)?.loginCommand ?? [], account: openCodeAcpAccountBinding(), installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')], - readStoredUserMessages: openCodeStoredUserMessagesReader(), visualsSkill: loadOpenCodeVisualsSkill, compaction: true, assertCopyLaunchArgs: assertOpenCodeCopyUsesLocalStore, diff --git a/src/main/acp/acp-recovery-history.test.ts b/src/main/acp/acp-recovery-history.test.ts deleted file mode 100644 index b972104bcb1..00000000000 --- a/src/main/acp/acp-recovery-history.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' -import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' -import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { acpLaunchSpecFor } from './acp-launch-specs' -import { readAcpRecoveryHistory, type AcpStoredUserMessage } from './acp-recovery-history' -import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' - -const SESSION = 'session-alpha-1' -const identity = { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'opencode', - providerHandle: null -} - -function fingerprint(text: string): string { - return computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } - }) -} - -function submission( - clientMessageId: string, - text: string, - dispatchState: AgentJournalSubmission['dispatchState'], - submittedAt: number, - resolvedAt: number | null = null -): AgentJournalSubmission { - return { - clientMessageId, - fence: 1, - payloadFingerprint: fingerprint(text), - dispatchState, - providerItemId: null, - reason: null, - submittedAt, - resolvedAt - } -} - -function journal(...submissions: AgentJournalSubmission[]): JournalLoad { - const state = createJournalReducerState(SESSION, 'epoch-1') - for (const entry of submissions) { - state.submissions.set(entry.clientMessageId, entry) - } - return { state, newer: null, damage: null } -} - -const launch: AcpStructuredLaunch = { - spec: acpLaunchSpecFor('opencode')!, - command: '/resolved/opencode', - args: ['acp'], - cwd: '/repo', - env: { XDG_DATA_HOME: '/data' }, - envToDelete: [], - fullAccess: false, - resume: { - sessionId: 'ses_1', - key: 'acp:opencode:ses_1', - mayBeUnsaved: () => false, - unannouncedLosses: () => [] - } -} - -function stored(id: string, text: string, createdAt: number): AcpStoredUserMessage { - return { id, blocks: [{ type: 'text', text }], createdAt } -} - -async function windowFor(load: JournalLoad | null, messages: AcpStoredUserMessage[] | null) { - const readStoredUserMessages = vi.fn(async () => messages) - const window = await readAcpRecoveryHistory( - { - spec: { readStoredUserMessages }, - readLaunch: async () => launch, - readJournal: () => load - }, - identity - ) - return { window, readStoredUserMessages } -} - -describe('ACP restart recovery from the agent store', () => { - it('settles a message the agent recorded after the crash as sent, under its own row', async () => { - const load = journal( - submission('first', 'hello', 'accepted', 100, 110), - submission('held', 'run the tests', 'unknown', 200) - ) - const { window, readStoredUserMessages } = await windowFor(load, [ - stored('msg_1', 'hello', 105), - stored('msg_2', 'run the tests', 205) - ]) - expect(readStoredUserMessages).toHaveBeenCalledWith( - expect.objectContaining({ env: launch.env, providerSessionId: 'ses_1' }) - ) - expect(window).toMatchObject({ boundaryConsistent: false, turnInFlight: true }) - expect( - reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) - ).toEqual([ - { - clientMessageId: 'held', - outcome: 'accepted', - providerItemId: 'msg_2', - identity: { provider: 'orca', clientMessageId: 'held' } - } - ]) - }) - - it('never offers an older identical message as the one in doubt', async () => { - const load = journal( - submission('first', 'continue', 'accepted', 100, 110), - submission('held', 'continue', 'unknown', 200) - ) - const { window } = await windowFor(load, [stored('msg_1', 'continue', 105)]) - expect(window?.items).toEqual([]) - expect( - reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) - ).toMatchObject([{ clientMessageId: 'held', outcome: 'unknown' }]) - }) - - it('lets an accepted send claim its own copy even when the agent stored it after the next send', async () => { - // Accepted at dispatch (r=500) and stored at 505; the identical send behind it was lost. - const load = journal( - submission('first', 'continue', 'accepted', 100, 500), - submission('held', 'continue', 'unknown', 110) - ) - const { window } = await windowFor(load, [stored('msg_1', 'continue', 505)]) - expect(window?.items).toEqual([]) - }) - - it('keeps an older unconfirmed send from widening the window over a delivered copy', async () => { - const load = journal( - submission('old', 'yes', 'unknown', 50), - submission('first', 'yes', 'accepted', 300, 300), - submission('held', 'yes', 'unknown', 400) - ) - const { window } = await windowFor(load, [stored('msg_1', 'yes', 303)]) - expect(window?.items).toEqual([]) - }) - - it('confirms the doubted send when both identical copies are stored', async () => { - const load = journal( - submission('first', 'yes', 'accepted', 300, 300), - submission('held', 'yes', 'unknown', 400) - ) - const { window } = await windowFor(load, [ - stored('msg_1', 'yes', 303), - stored('msg_2', 'yes', 405) - ]) - expect( - reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) - ).toMatchObject([{ clientMessageId: 'held', outcome: 'accepted', providerItemId: 'msg_2' }]) - }) - - it('reads nothing for a queued card the agent was never handed', async () => { - const queued = { ...submission('queued', 'x', 'pending', 1), handoverRecorded: true as const } - const { window, readStoredUserMessages } = await windowFor(journal(queued), []) - expect(window).toBeNull() - expect(readStoredUserMessages).not.toHaveBeenCalled() - }) - - it('reads nothing with no message in doubt, a damaged journal, or no resumable session', async () => { - const accepted = await windowFor(journal(submission('a', 'x', 'accepted', 1, 2)), []) - expect(accepted.window).toBeNull() - expect(accepted.readStoredUserMessages).not.toHaveBeenCalled() - const damaged = journal(submission('held', 'x', 'unknown', 1)) - const broken = await windowFor( - { ...damaged, damage: { sequence: 3, cause: 'sequence-gap' } }, - [] - ) - expect(broken.window).toBeNull() - expect(broken.readStoredUserMessages).not.toHaveBeenCalled() - const readStoredUserMessages = vi.fn(async () => []) - await expect( - readAcpRecoveryHistory( - { - spec: { readStoredUserMessages }, - readLaunch: async () => ({ ...launch, resume: null }), - readJournal: () => journal(submission('held', 'x', 'unknown', 1)) - }, - identity - ) - ).resolves.toBeNull() - expect(readStoredUserMessages).not.toHaveBeenCalled() - }) - - it('reads nothing for an agent whose store Orca does not read', async () => { - await expect( - readAcpRecoveryHistory( - { - spec: {}, - readLaunch: async () => launch, - readJournal: () => journal(submission('held', 'x', 'unknown', 1)) - }, - identity - ) - ).resolves.toBeNull() - }) - - it('answers null, never throws, when the store cannot be read', async () => { - const warn = vi.fn() - await expect( - readAcpRecoveryHistory( - { - spec: { - readStoredUserMessages: async () => { - throw new Error('database is locked') - } - }, - readLaunch: async () => launch, - readJournal: () => journal(submission('held', 'x', 'unknown', 1)), - logger: { warn, error: vi.fn() } - }, - identity - ) - ).resolves.toBeNull() - expect(warn).toHaveBeenCalled() - }) -}) diff --git a/src/main/acp/acp-recovery-history.ts b/src/main/acp/acp-recovery-history.ts deleted file mode 100644 index d23a8f49651..00000000000 --- a/src/main/acp/acp-recovery-history.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Recovery proves delivery only by presence; missing history never proves non-delivery. - -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation' -import type { - AgentJournalSubmission, - AgentSessionJournalIdentity -} from '../../shared/agent-session-journal-types' -import type { NativeChatBlock } from '../../shared/native-chat-types' -import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' -import type { - ProviderHistoryItem, - ProviderHistoryWindow -} from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { isQueuedAgentJournalSubmission } from '../../shared/agent-session-queued-submission' -import type { AcpLaunchSpec } from './acp-launch-specs' -import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' - -const RECOVERY_READ_MS = 5_000 - -export type AcpStoredUserMessage = { - /** Provider ID, claimed at most once. */ - id: string - blocks: NativeChatBlock[] - /** Epoch milliseconds on the execution host's clock. */ - createdAt: number -} - -export type AcpStoredUserMessagesReader = (input: { - env: Record - providerSessionId: string - signal: AbortSignal -}) => Promise - -// Queued submissions never reached the agent. -function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] { - return [...load.state.submissions.values()].filter( - (submission) => - (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') && - !isQueuedAgentJournalSubmission(submission) - ) -} - -function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string { - return agentSessionSendBodyFingerprint(sessionId, { - kind: 'message', - role: 'user', - blocks: message.blocks - }) -} - -/** Claim accepted sends' matching copies first to avoid falsely confirming identical sends in doubt. */ -function recoveryWindow( - load: JournalLoad, - inDoubt: readonly AgentJournalSubmission[], - messages: readonly AcpStoredUserMessage[] -): ProviderHistoryItem[] { - const submissions = [...load.state.submissions.values()] - const from = Math.min(...inDoubt.map((submission) => submission.submittedAt)) - const stored = messages - .map((message) => ({ message, fingerprint: fingerprintOf(load.state.sessionId, message) })) - .toSorted((left, right) => left.message.createdAt - right.message.createdAt) - const claimed = new Set() - const accepted = submissions - .filter((submission) => submission.dispatchState === 'accepted') - .toSorted((left, right) => left.submittedAt - right.submittedAt) - for (const submission of accepted) { - const index = stored.findIndex( - ({ message, fingerprint }, at) => - !claimed.has(at) && - fingerprint === submission.payloadFingerprint && - message.createdAt >= submission.submittedAt - ) - if (index !== -1) { - claimed.add(index) - } - } - return stored.flatMap(({ message, fingerprint }, at) => - claimed.has(at) || message.createdAt < from - ? [] - : [{ providerItemId: message.id, clientMessageId: null, payloadFingerprint: fingerprint }] - ) -} - -export async function readAcpRecoveryHistory( - input: Pick & { - spec: Pick - }, - identity: AgentSessionJournalIdentity -): Promise { - const { readStoredUserMessages } = input.spec - if (!readStoredUserMessages || !input.readJournal) { - return null - } - const sessionId = identity.sessionId - const signal = AbortSignal.timeout(RECOVERY_READ_MS) - try { - const load = input.readJournal(sessionId) - const inDoubt = load && !load.damage && !load.newer ? sendsInDoubt(load) : [] - if (!load || inDoubt.length === 0) { - return null - } - const launch = await waitForPromiseWithSignal(input.readLaunch(identity), signal) - if (!launch.resume) { - return null - } - const messages = await waitForPromiseWithSignal( - readStoredUserMessages({ - env: launch.env, - providerSessionId: launch.resume.sessionId, - signal - }), - signal - ) - if (!messages) { - return null - } - return { - // Neither the read boundary nor completion of the agent's work is known. - boundaryConsistent: false, - turnInFlight: true, - items: recoveryWindow(load, inDoubt, messages) - } - } catch (error) { - input.logger?.warn('ACP recovery history could not be read', { - scope: 'acp-recovery-history', - sessionId, - error - }) - return null - } -} diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index db2c431d38d..82a3b11da85 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -6,7 +6,6 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import type { AcpLaunchSpec } from './acp-launch-specs' import type { ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' @@ -26,12 +25,10 @@ export type AcpStructuredSessionAdapterDeps = { /** Defaults to ask. */ permissionMode?: AgentChatPermissionMode }) => Promise - /** Recovery reads use the same launch with an unsealed environment. */ + /** The same launch for a read that starts no child (a conversation copy): the agent's env, unsealed. */ readLaunch: (identity: AgentSessionJournalIdentity) => Promise /** Current host arguments before protocol-specific filtering; copies open through the CLI. */ resolveLaunchArgs?: () => Promise | string[] - /** Reads without opening the journal. */ - readJournal?: (sessionId: string) => JournalLoad | null /** Protected by this host's instance lock. */ dataDirectory: string connect: ConnectAcpAgent diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 86cc8f38495..c3255c18604 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -30,7 +30,6 @@ import { type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' -import { readAcpRecoveryHistory } from './acp-recovery-history' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' import { acpStartupFailure, isAcpReadySession } from './acp-structured-child' import { AcpStructuredChildren } from './acp-structured-child-lifecycle' @@ -45,10 +44,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte this.copies = new AcpConversationCopies(deps) } - providerHistoryWindow: NonNullable = ( - input - ) => readAcpRecoveryHistory(this.deps, input.identity) - copyConversation: NonNullable = (input) => this.copies.copy(input) diff --git a/src/main/claude/claude-history-window-source-budget.test.ts b/src/main/claude/claude-history-window-source-budget.test.ts deleted file mode 100644 index e1ddeca49a9..00000000000 --- a/src/main/claude/claude-history-window-source-budget.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { FileHandle } from 'node:fs/promises' -import type * as FsPromises from 'node:fs/promises' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const state = vi.hoisted(() => ({ - path: '', - growth: '', - readError: false, - bytesRead: 0, - peakChunkBytes: 0, - streams: 0, - closes: 0, - opens: 0, - observedStatBytes: 0 -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const fs = await importOriginal() - return { - ...fs, - open: async (path: string, flags: string) => { - const handle = await fs.open(path, flags) - if (path !== state.path) { - return handle - } - state.opens += 1 - return { - stat: async () => { - const snapshot = await handle.stat() - state.observedStatBytes = snapshot.size - if (state.growth) { - const growth = state.growth - state.growth = '' - await fs.appendFile(path, growth) - } - return snapshot - }, - createReadStream: (options: Parameters[0]) => { - state.streams += 1 - const stream = handle.createReadStream(options) - if (state.readError) { - queueMicrotask(() => stream.destroy(new Error('Injected read failure'))) - return stream - } - stream.on('data', (chunk: Buffer | string) => { - const size = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk) - state.bytesRead += size - state.peakChunkBytes = Math.max(state.peakChunkBytes, size) - }) - return stream - }, - close: async () => { - state.closes += 1 - await handle.close() - } - } - } - } -}) - -import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { readClaudeProviderHistoryWindow } from './claude-structured-history-window' - -const LEGACY_LIMIT = 16 * 1024 * 1024 -const SOURCE = `${[ - { - type: 'user', - uuid: 'anchor', - parentUuid: null, - sessionId: 'provider', - message: { role: 'user', content: 'before' } - }, - { - type: 'user', - uuid: 'latest', - parentUuid: 'anchor', - sessionId: 'provider', - message: { role: 'user', content: 'after' } - }, - { type: 'last-prompt', sessionId: 'provider', leafUuid: 'latest' } -] - .map((row) => JSON.stringify(row)) - .join('\n')}\n` -let directory = '' - -const read = (previousLeafUuid: string | null = 'anchor') => - readClaudeProviderHistoryWindow({ - transcriptPath: state.path, - providerSessionId: 'provider', - previousLeafUuid, - sessionId: 'orca', - turnInFlight: false - }) - -function padTo(bytes: number): string { - const filler = `${JSON.stringify({ type: 'comment', note: 'x'.repeat(4096) })}\n` - const rows = Math.ceil((bytes - Buffer.byteLength(SOURCE)) / Buffer.byteLength(filler)) - return filler.repeat(Math.max(rows, 0)) + SOURCE -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-history-source-budget-')) - Object.assign(state, { - path: join(directory, 'session.jsonl'), - growth: '', - readError: false, - bytesRead: 0, - peakChunkBytes: 0, - streams: 0, - opens: 0, - closes: 0, - observedStatBytes: 0 - }) - await writeFile(state.path, SOURCE) -}) - -afterEach(async (context) => { - try { - const output = process.env.ORCA_HISTORY_BUDGET_PROOF_OUTPUT - if (output) { - await appendFile( - output, - `${JSON.stringify({ - test: context.task.name, - bytesRead: state.bytesRead, - peakChunkBytes: state.peakChunkBytes, - observedStatBytes: state.observedStatBytes, - streams: state.streams, - opens: state.opens, - closes: state.closes - })}\n` - ) - } - expect(state.closes).toBe(state.opens) - } finally { - await rm(directory, { recursive: true, force: true }) - } -}) - -describe('Claude provider history source budget', () => { - it('reads a stable history without changing prompt evidence', async () => { - const result = await read() - expect(result.boundaryConsistent).toBe(true) - expect(result.items.map((item) => item.providerItemId)).toEqual(['latest']) - }) - - it('streams two passes over one descriptor rather than one whole-file read', async () => { - await read() - // One open for both passes: pass 2 must see the bytes pass 1 vouched for. - expect(state.opens).toBe(1) - expect(state.streams).toBe(2) - expect(state.bytesRead).toBe(2 * Buffer.byteLength(SOURCE)) - }) - - it('opens no second pass when nothing followed the anchor', async () => { - await writeFile( - state.path, - SOURCE.split('\n') - // Keep the marker so malformed input cannot make the no-replay assertion pass vacuously. - .filter((line) => !line.includes('"uuid":"latest"')) - .join('\n') - .replace('"leafUuid":"latest"', '"leafUuid":"anchor"') - ) - - const result = await read() - - expect(result.boundaryConsistent).toBe(true) - expect(result.items).toEqual([]) - expect(state.streams).toBe(1) - }) - - it('resolves a source past the legacy whole-file limit instead of refusing it', async () => { - // The old bounded read returned an inconsistent boundary here, which is the - // one answer reconciliation can never act on. - await writeFile(state.path, padTo(LEGACY_LIMIT + 1)) - expect(state.observedStatBytes).toBe(0) - - const result = await read() - - expect(result.boundaryConsistent).toBe(true) - expect(result.items.map((item) => item.providerItemId)).toEqual(['latest']) - expect(state.bytesRead).toBeGreaterThan(2 * LEGACY_LIMIT) - }) - - it('reads a large history in chunks smaller than 1 MiB', async () => { - await writeFile(state.path, padTo(LEGACY_LIMIT + 1)) - expect((await read()).boundaryConsistent).toBe(true) - // Bound chunks handed to the framer; this does not measure retained memory. - expect(state.peakChunkBytes).toBeLessThan(1024 * 1024) - }) - - it('refuses a single record too large to frame', async () => { - const huge = `${JSON.stringify({ type: 'comment', note: 'x'.repeat(LEGACY_LIMIT) })}\n` - await writeFile(state.path, huge + SOURCE) - expect((await read()).boundaryConsistent).toBe(false) - }) - - it('replays a concurrent repair at the grown size, not the pinned one', async () => { - const grown = `${JSON.stringify({ - type: 'user', - uuid: 'grown', - parentUuid: 'latest', - sessionId: 'provider', - message: { role: 'user', content: 'appended' } - })}\n` - const torn = Math.floor(grown.length / 2) - await writeFile(state.path, SOURCE + grown.slice(0, torn)) - state.growth = grown.slice(torn) - - const result = await read() - - // The retry re-runs BOTH passes at the new size: a pass 2 left at the old - // size would silently drop the record the proof just accepted. - expect(result.boundaryConsistent).toBe(true) - expect(result.items.map((item) => item.providerItemId)).toEqual(['latest', 'grown']) - // One failed proof, a refreshed proof, then replay. - expect(state.streams).toBe(3) - expect(state.opens).toBe(1) - }) - - it('preserves the inconsistent result on a read error', async () => { - state.readError = true - expect((await read()).boundaryConsistent).toBe(false) - }) - - it('does not open a source without an anchor', async () => { - expect((await read(null)).boundaryConsistent).toBe(false) - expect(state.opens).toBe(0) - expect(state.bytesRead).toBe(0) - }) -}) diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts deleted file mode 100644 index 1babe2ca13c..00000000000 --- a/src/main/claude/claude-structured-history-window.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { mkdir, 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 { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-send-mutation' -import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation' -import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { - claudeProviderHistoryWindowFromJsonl, - resolveClaudeProviderHistoryWindow -} from './claude-structured-history-window' -import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' - -const PROVIDER_SESSION = 'provider-1' -const ORCA_SESSION = 'session-1' - -let accountHome: string - -type Row = Record - -function prompt(uuid: string, parentUuid: string | null, content: unknown, extra: Row = {}): Row { - return { - type: 'user', - uuid, - parentUuid, - sessionId: PROVIDER_SESSION, - message: { role: 'user', content }, - ...extra - } -} - -function jsonl(rows: Row[], leafUuid: string): string { - const lines = [...rows, { type: 'last-prompt', sessionId: PROVIDER_SESSION, leafUuid }] - return `${lines.map((row) => JSON.stringify(row)).join('\n')}\n` -} - -function read(contents: string, previousLeafUuid: string | null, turnInFlight = false) { - return claudeProviderHistoryWindowFromJsonl({ - contents, - providerSessionId: PROVIDER_SESSION, - previousLeafUuid, - sessionId: ORCA_SESSION, - turnInFlight - }) -} - -function sendFingerprint(text: string): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: ORCA_SESSION, - fields: { body: structuredAgentSessionSendBody(text, []) } - }) -} - -const ANCHOR = prompt('anchor', null, 'earlier turn') - -beforeEach(async () => { - accountHome = await mkdtemp(join(tmpdir(), 'orca-claude-history-window-')) -}) - -afterEach(async () => { - await rm(accountHome, { recursive: true, force: true }) -}) - -describe('claudeProviderHistoryWindowFromJsonl', () => { - it('resolves history from the session account home, not the process default', async () => { - const transcriptPath = join(accountHome, 'projects', 'work', `${PROVIDER_SESSION}.jsonl`) - await mkdir(join(accountHome, 'projects', 'work'), { recursive: true }) - await writeFile( - transcriptPath, - jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), - 'utf8' - ) - - const window = await resolveClaudeProviderHistoryWindow({ - identity: { - sessionId: ORCA_SESSION, - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle(PROVIDER_SESSION, 'anchor') - }, - accountHomePath: accountHome, - hasLiveSession: false - }) - - expect(window?.items.map((item) => item.providerItemId)).toEqual(['u-1']) - }) - - it('fingerprints a prompt after the anchor exactly as the send that produced it', () => { - const contents = jsonl( - [ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: 'ship it' }])], - 'u-1' - ) - - const window = read(contents, 'anchor') - - expect(window.boundaryConsistent).toBe(true) - expect(window.items).toEqual([ - { - providerItemId: 'u-1', - clientMessageId: null, - payloadFingerprint: sendFingerprint('ship it'), - identity: { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'u-1' } - } - ]) - }) - - it('fingerprints a string-content prompt the same as a block-content one', () => { - const asString = read(jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), 'anchor') - - expect(asString.items[0]?.payloadFingerprint).toBe(sendFingerprint('ship it')) - }) - - it('preserves leading whitespace when fingerprinting a text block', () => { - const contents = jsonl( - [ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: ' ship it' }])], - 'u-1' - ) - - expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe(sendFingerprint(' ship it')) - }) - - it('excludes everything before the anchor', () => { - const contents = jsonl( - [ - prompt('root', null, 'first'), - prompt('anchor', 'root', 'second'), - prompt('u-1', 'anchor', 'third') - ], - 'u-1' - ) - - expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-1']) - }) - - it('reports no window and an inconsistent boundary without a durable anchor', () => { - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') - - expect(read(contents, null)).toEqual({ - items: [], - boundaryConsistent: false, - turnInFlight: false - }) - }) - - it('reports an inconsistent boundary when the anchor is gone from the file', () => { - const contents = jsonl([prompt('u-1', null, 'ship it')], 'u-1') - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('reports an inconsistent boundary when the leaf is on a sibling branch', () => { - const contents = jsonl( - [ - prompt('root', null, 'first'), - prompt('anchor', 'root', 'second'), - prompt('u-1', 'root', 'branched') - ], - 'u-1' - ) - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('reports an inconsistent boundary on a torn tail', () => { - const contents = `${jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1')}{"type":"user"` - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('keeps the boundary consistent and the window empty when nothing followed the anchor', () => { - expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toEqual({ - items: [], - boundaryConsistent: true, - turnInFlight: false - }) - }) - - it('excludes harness-injected turns, meta turns, tool results and sidechains', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('u-reminder', 'anchor', [ - { type: 'text', text: 'be careful' } - ]), - prompt('u-meta', 'u-reminder', [{ type: 'text', text: 'injected' }], { isMeta: true }), - prompt('u-tool', 'u-meta', [{ type: 'tool_result', content: 'ok' }]), - prompt('u-real', 'u-tool', 'ship it') - ], - 'u-real' - ) - - expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-real']) - }) - - it('excludes a prompt carrying an image, whose path the transcript does not keep', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('u-img', 'anchor', [ - { type: 'text', text: 'look at this' }, - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } } - ]) - ], - 'u-img' - ) - - expect(read(contents, 'anchor').items).toEqual([]) - expect(read(contents, 'anchor').boundaryConsistent).toBe(true) - }) - - it('keeps the FIRST record for a repeated uuid, as the whole-file index did', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('u-1', 'anchor', 'first copy'), - prompt('u-1', 'anchor', 'second copy'), - prompt('u-2', 'u-1', 'next') - ], - 'u-2' - ) - - expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe( - sendFingerprint('first copy') - ) - }) - - it('drops a repeated uuid whose first copy is not a prompt', () => { - const contents = jsonl( - [ - ANCHOR, - prompt('u-1', 'anchor', 'injected', { isMeta: true }), - prompt('u-1', 'anchor', 'real prompt') - ], - 'u-1' - ) - - expect(read(contents, 'anchor').items).toEqual([]) - expect(read(contents, 'anchor').boundaryConsistent).toBe(true) - }) - - it('refuses a malformed line rather than skipping past it into the window', () => { - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1').replace( - '{"type":"last-prompt"', - 'not json\n{"type":"last-prompt"' - ) - - expect(read(contents, 'anchor').boundaryConsistent).toBe(false) - }) - - it('carries the caller-proven turn-in-flight fact through to the window', () => { - const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') - - expect(read(contents, 'anchor', true).turnInFlight).toBe(true) - }) -}) - -describe('a crash between Claude saving a prompt and Orca recording its echo', () => { - const row = (type: string, uuid: string, parentUuid: string | null, extra: Row = {}): Row => ({ - type, - uuid, - parentUuid, - isSidechain: false, - sessionId: PROVIDER_SESSION, - ...extra - }) - const marker = (leafUuid: string): Row => ({ - type: 'last-prompt', - sessionId: PROVIDER_SESSION, - leafUuid - }) - const side = (type: string): Row => ({ type, sessionId: PROVIDER_SESSION }) - // Match CLI 2.1.280: a mid-turn crash leaves a prompt beyond the last stop-hook marker. - const CRASHED_MID_TURN = [ - side('queue-operation'), - row('attachment', 'hook-start', null, { attachment: { type: 'hook_success' } }), - prompt('alpha', 'hook-start', [{ type: 'text', text: 'ALPHA' }]), - row('attachment', 'alpha-context', 'alpha', { attachment: { type: 'date' } }), - marker('alpha-context'), - side('ai-title'), - row('assistant', 'alpha-reply', 'alpha-context', { - message: { role: 'assistant', content: [{ type: 'text', text: 'ALPHA' }] } - }), - row('attachment', 'alpha-hook', 'alpha-reply', { attachment: { type: 'hook_success' } }), - row('system', 'alpha-stop-summary', 'alpha-hook', { subtype: 'stop_hook_summary' }), - marker('alpha-stop-summary'), - side('queue-operation'), - prompt('bravo', 'alpha-stop-summary', [{ type: 'text', text: 'BRAVO' }]) - ] - .map((entry) => JSON.stringify(entry)) - .join('\n') - - it('reconciles the prompt Claude already holds as accepted, not undelivered', () => { - const window = read(`${CRASHED_MID_TURN}\n`, 'alpha-reply') - expect(window).toMatchObject({ boundaryConsistent: true }) - expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo']) - - const [verdict] = reconcileSubmissions({ - history: window, - submissions: [ - { - clientMessageId: 'bravo-send', - fence: 1, - payloadFingerprint: sendFingerprint('BRAVO'), - dispatchState: 'unknown', - providerItemId: null, - reason: null, - submittedAt: 0, - resolvedAt: null - } - ] - }) - expect(verdict).toMatchObject({ clientMessageId: 'bravo-send', outcome: 'accepted' }) - }) - - it('ends the conversation at the last main-chain row, never a trailing sidechain row', () => { - const subagent = row('assistant', 'subagent-reply', null, { isSidechain: true }) - const window = read(`${CRASHED_MID_TURN}\n${JSON.stringify(subagent)}\n`, 'alpha-reply') - expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo']) - }) -}) diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts deleted file mode 100644 index e19fbbaa307..00000000000 --- a/src/main/claude/claude-structured-history-window.ts +++ /dev/null @@ -1,251 +0,0 @@ -// Reconcile sends against the transcript Claude resumes, from the durable anchor to the file tail. -// Unproven ancestry makes absence inconclusive, so report an inconsistent boundary, not an empty window. - -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { - CLAUDE_STRUCTURED_HANDLE_NAMESPACE, - claudeProviderHandleLeafUuid, - isAgentSessionProviderHandleInNamespace -} from '../../shared/agent-session-provider-handle-encoding' -import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import type { - ProviderHistoryItem, - ProviderHistoryWindow -} from '../native-chat/agent-session-journal/journal-submission-reconciler' -import { claudeContentBlocks } from '../native-chat/transcript-record-blocks' -import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import type { NativeChatBlock } from '../../shared/native-chat-types' -import { - replayClaudeTranscriptBranchAncestry, - replayClaudeTranscriptBranchAncestryFromJsonl -} from './claude-transcript-branch-proof' - -// Bound each JSONL record without imposing a whole-file limit. -const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024 - -const INCONSISTENT: ProviderHistoryWindow = { - items: [], - boundaryConsistent: false, - turnInFlight: false -} - -type TranscriptRecord = Record - -function stringField(source: unknown, key: string): string | null { - if (!source || typeof source !== 'object') { - return null - } - const value = (source as Record)[key] - return typeof value === 'string' && value.trim() ? value : null -} - -function isPlainTextPart(part: unknown): boolean { - if (typeof part === 'string') { - return true - } - return Boolean(part) && typeof part === 'object' && (part as TranscriptRecord).type === 'text' -} - -/** Recover the text bytes Claude received; the shared decoder trims for display. */ -function rawTextParts(content: unknown): string[] | null { - if (typeof content === 'string') { - return content.trim() ? [content] : [] - } - if (!Array.isArray(content)) { - return [] - } - const parts: string[] = [] - for (const part of content) { - if (typeof part === 'string') { - if (part.trim()) { - parts.push(part) - } - continue - } - const record = part && typeof part === 'object' ? (part as TranscriptRecord) : null - if (!record || record.type !== 'text' || typeof record.text !== 'string') { - return null - } - if (record.text.trim()) { - parts.push(record.text) - } - } - return parts -} - -/** Exclude injected turns and attachments: transcript images lack the paths needed for send fingerprints. */ -function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null { - if ( - record.type !== 'user' || - record.isSidechain === true || - record.parent_tool_use_id != null || - record.isMeta === true || - record.isSynthetic === true || - record.isCompactSummary === true - ) { - return null - } - const message = record.message - const content = - message && typeof message === 'object' ? (message as TranscriptRecord).content : undefined - // Inspect raw content because the display decoder omits images, which would change fingerprints. - if (Array.isArray(content) && !content.every((part) => isPlainTextPart(part))) { - return null - } - const blocks = claudeContentBlocks(content) - if (blocks.length === 0 || blocks.some((block) => block.type !== 'text')) { - return null - } - const rawTexts = rawTextParts(content) - if (rawTexts === null || rawTexts.length !== blocks.length) { - return null - } - const preservedBlocks = blocks.map((block, index) => ({ - ...block, - text: rawTexts[index]! - })) - const [first] = preservedBlocks - if (first?.type !== 'text' || isKnownHarnessInjectedUserTurnText(first.text)) { - return null - } - return preservedBlocks -} - -/** Use the same canonical fingerprint enforced at send admission. */ -function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { - return computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body: { kind: 'message', role: 'user', blocks } } - }) -} - -type HistoryWindowInput = { - providerSessionId: string - previousLeafUuid: string | null - /** Send fingerprints are scoped to the Orca session. */ - sessionId: string - /** Absence is conclusive only when the caller proves no provider child can still append. */ - turnInFlight: boolean -} - -/** Retain fingerprints, not prompt bodies, while replaying the proven branch. */ -function createWindowCollector(input: HistoryWindowInput) { - const byUuid = new Map() - return { byUuid, onAncestorRecord } - - function onAncestorRecord(record: TranscriptRecord, uuid: string): void { - const blocks = claudePromptBlocks(record) - if (!blocks) { - return - } - byUuid.set(uuid, { - providerItemId: uuid, - // Claude omits client message IDs; reconciliation must use weaker fingerprint evidence. - clientMessageId: null, - payloadFingerprint: promptFingerprint(input.sessionId, blocks), - identity: { - provider: 'claude', - sessionId: stringField(record, 'sessionId') ?? input.providerSessionId, - uuid - } - }) - } -} - -function windowFromChain( - chain: readonly string[], - byUuid: Map, - turnInFlight: boolean -): ProviderHistoryWindow { - const items: ProviderHistoryItem[] = [] - for (const uuid of chain.toReversed()) { - const item = byUuid.get(uuid) - if (item) { - items.push(item) - } - } - return { items, boundaryConsistent: true, turnInFlight } -} - -export function claudeProviderHistoryWindowFromJsonl( - input: HistoryWindowInput & { contents: string } -): ProviderHistoryWindow { - const ancestryAnchorUuid = input.previousLeafUuid - if (!ancestryAnchorUuid) { - return INCONSISTENT - } - const collector = createWindowCollector(input) - try { - const { chain } = replayClaudeTranscriptBranchAncestryFromJsonl({ - contents: input.contents, - providerSessionId: input.providerSessionId, - previousLeafUuid: ancestryAnchorUuid, - ancestryAnchorUuid, - onAncestorRecord: collector.onAncestorRecord - }) - return windowFromChain(chain, collector.byUuid, input.turnInFlight) - } catch { - // Unproven ancestry cannot establish that a send is absent. - return INCONSISTENT - } -} - -/** A live child can still append queued sends, so transcript absence cannot prove non-delivery. */ -export async function resolveClaudeProviderHistoryWindow(input: { - identity: AgentSessionJournalIdentity - accountHomePath: string - hasLiveSession: boolean -}): Promise { - const handle = input.identity.providerHandle - if ( - !handle || - !isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) - ) { - return null - } - const transcriptPath = await resolveSessionFilePath('claude', handle.nativeId, { - claudeProjectsDir: join(input.accountHomePath, 'projects') - }) - if (!transcriptPath) { - return null - } - return readClaudeProviderHistoryWindow({ - transcriptPath, - providerSessionId: handle.nativeId, - previousLeafUuid: claudeProviderHandleLeafUuid(handle), - sessionId: input.identity.sessionId, - turnInFlight: input.hasLiveSession - }) -} - -export async function readClaudeProviderHistoryWindow( - input: HistoryWindowInput & { transcriptPath: string } -): Promise { - const ancestryAnchorUuid = input.previousLeafUuid - if (!ancestryAnchorUuid) { - return INCONSISTENT - } - const collector = createWindowCollector(input) - try { - const { chain } = await replayClaudeTranscriptBranchAncestry({ - transcriptPath: input.transcriptPath, - providerSessionId: input.providerSessionId, - previousLeafUuid: ancestryAnchorUuid, - ancestryAnchorUuid, - maxRecordBytes: MAX_HISTORY_WINDOW_RECORD_BYTES, - onAncestorRecord: collector.onAncestorRecord - }) - return windowFromChain(chain, collector.byUuid, input.turnInFlight) - } catch (error) { - // Read, ancestry, and record-size failures leave absence inconclusive. - console.warn('[claude-history-window] transcript unprovable; boundary inconsistent:', { - transcriptPath: input.transcriptPath, - sessionId: input.sessionId, - error - }) - return INCONSISTENT - } -} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e2534bd36b8..8afc7b7dff1 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,3 @@ -import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { @@ -35,7 +34,6 @@ import { type ClaudeExitLifecycle } from './claude-structured-session-exit-lifecycle' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' import { drainClaudeChildWork } from './claude-child-work-evidence' import { emitClaudeStructuredSessionEvent } from './claude-structured-event-delivery' import { @@ -118,17 +116,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda /** Await exit publication after cleanup/cursor writes; unproven exits stay indexed for retry. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ - providerHistoryWindow: NonNullable = ( - input - ) => - resolveClaudeProviderHistoryWindow({ - identity: input.identity, - accountHomePath: requireLegacyAgentSessionAccountHome(input.accountHome).path, - hasLiveSession: - this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) - }) - private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { emitClaudeStructuredSessionEvent({ session, diff --git a/src/main/claude/claude-transcript-branch-graph.test.ts b/src/main/claude/claude-transcript-branch-graph.test.ts deleted file mode 100644 index 93a1bbd370d..00000000000 --- a/src/main/claude/claude-transcript-branch-graph.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ClaudeTranscriptMarkerMissingError, - createBranchProof -} from './claude-transcript-branch-graph' - -const row = (uuid: string, parentUuid: string | null): string => - JSON.stringify({ type: 'user', uuid, parentUuid, sessionId: 'provider' }) - -function build(lines: string[], previousLeafUuid: string | null, tip?: 'marker' | 'file-tail') { - const builder = createBranchProof({ - providerSessionId: 'provider', - previousLeafUuid, - ...(tip === undefined ? {} : { tip }) - }) - for (const [index, line] of lines.entries()) { - builder.add(line, index, true) - } - return builder -} - -describe('createBranchProof file-tail tip', () => { - const MARKERLESS = [row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid')] - - it('proves the tip from the last main-chain row with no last-prompt row anywhere', () => { - expect(build(MARKERLESS, 'anchor', 'file-tail').finish()).toEqual({ - leafUuid: 'leaf', - relation: 'descendant' - }) - }) - - it('still requires the marker on the same bytes in marker mode', () => { - expect(() => build(MARKERLESS, 'anchor').finish()).toThrow(ClaudeTranscriptMarkerMissingError) - }) -}) - -describe('createBranchProof ancestry chain', () => { - const MARKER = JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid: 'leaf' }) - - it('returns the leaf-first chain back to, but excluding, the anchor', () => { - const builder = build( - [row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid'), MARKER], - 'anchor' - ) - builder.finish() - - expect(builder.ancestryChain('leaf', 'anchor')).toEqual(['leaf', 'mid']) - }) - - it('returns empty when the leaf IS the anchor', () => { - const builder = build( - [row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid'), MARKER], - 'anchor' - ) - builder.finish() - - expect(builder.ancestryChain('leaf', 'leaf')).toEqual([]) - }) - - it('throws rather than reporting empty when the walk never reaches the anchor', () => { - const builder = build( - [row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid'), MARKER], - 'anchor' - ) - builder.finish() - - expect(() => builder.ancestryChain('leaf', 'absent')).toThrow('does not reach anchor absent') - }) -}) diff --git a/src/main/claude/claude-transcript-branch-graph.ts b/src/main/claude/claude-transcript-branch-graph.ts deleted file mode 100644 index 2626a785745..00000000000 --- a/src/main/claude/claude-transcript-branch-graph.ts +++ /dev/null @@ -1,253 +0,0 @@ -const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000 - -class ClaudeTranscriptMarkerMissingError extends Error { - constructor() { - super('Claude transcript branch proof failed: missing last-prompt marker') - } -} - -type TranscriptNode = { - parentUuid: string | null - sessionId: string | null - lineIndex: number - disallowedLeaf: boolean -} - -export type ClaudeTranscriptBranchProof = { - leafUuid: string - relation: 'initial' | 'same' | 'descendant' -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null -} - -function transcriptError(reason: string): Error { - return new Error(`Claude transcript branch proof failed: ${reason}`) -} - -export class ClaudeTranscriptTailIncompleteError extends Error { - constructor() { - super('Claude transcript branch proof failed: malformed JSONL') - this.name = 'ClaudeTranscriptTailIncompleteError' - } -} - -export class ClaudeTranscriptPreviousCursorMissingError extends Error { - constructor() { - super( - 'Claude transcript branch proof failed: previous cursor is missing from the session graph' - ) - this.name = 'ClaudeTranscriptPreviousCursorMissingError' - } -} - -function proveMainLineAncestry( - nodes: Map, - startUuid: string, - providerSessionId: string -): void { - const visited = new Set() - let cursor: string | null = startUuid - for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { - if (visited.has(cursor)) { - throw transcriptError('cycle in parentUuid ancestry') - } - visited.add(cursor) - const node = nodes.get(cursor) - if (!node || node.sessionId !== providerSessionId) { - throw transcriptError(`missing ancestor ${cursor}`) - } - if (node.disallowedLeaf) { - throw transcriptError(`ancestor ${cursor} is not on the main transcript`) - } - cursor = node.parentUuid - } - if (cursor !== null) { - throw transcriptError('ancestry exceeds the bounded proof limit') - } -} - -function proveAppendOrder(nodes: Map): void { - for (const node of nodes.values()) { - if (!node.parentUuid) { - continue - } - const parent = nodes.get(node.parentUuid) - if (parent && parent.lineIndex >= node.lineIndex) { - throw transcriptError('parent row follows descendant') - } - } -} - -const TRANSCRIPT_TAIL_TYPES: ReadonlySet = new Set([ - 'user', - 'assistant', - 'system', - 'attachment' -]) - -type BranchProofInput = { - providerSessionId: string - previousLeafUuid: string | null - /** Defaults to marker; file-tail includes rows saved after a marker during a crash. */ - tip?: 'marker' | 'file-tail' -} - -function createBranchProof(input: BranchProofInput) { - const nodes = new Map() - let leafUuid: string | null = null - let leafMarkerLineIndex = -1 - let tailUuid: string | null = null - let tailLineIndex = -1 - return { add, finish, ancestryChain } - - function add(line: string, index: number, terminated: boolean): void { - if (!line.trim()) { - return - } - let record: unknown - try { - record = JSON.parse(line) - } catch { - if (!terminated) { - throw new ClaudeTranscriptTailIncompleteError() - } - throw transcriptError('malformed JSONL') - } - if (typeof record !== 'object' || record === null || Array.isArray(record)) { - throw transcriptError('non-object record') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The parsed value is a non-array object checked above. - const row = record as Record - if (row.type === 'last-prompt') { - const markerSessionId = nonEmptyString(row.sessionId) - const markerLeaf = nonEmptyString(row.leafUuid) - if (markerSessionId !== input.providerSessionId || !markerLeaf) { - throw transcriptError('invalid last-prompt marker') - } - leafUuid = markerLeaf - leafMarkerLineIndex = index - } - const uuid = nonEmptyString(row.uuid) - if (!uuid) { - return - } - const parentUuid = row.parentUuid === null ? null : nonEmptyString(row.parentUuid) - if (row.parentUuid !== null && !parentUuid) { - throw transcriptError(`record ${uuid} has no parent identity`) - } - const sessionId = nonEmptyString(row.sessionId) - const existing = nodes.get(uuid) - const disallowedLeaf = - row.isSidechain === true || - row.parent_tool_use_id != null || - row.type === 'result' || - row.type === 'stream_event' || - (row.type === 'system' && row.subtype === 'init') - if ( - existing && - (existing.parentUuid !== parentUuid || - existing.sessionId !== sessionId || - existing.disallowedLeaf !== disallowedLeaf) - ) { - throw transcriptError(`record ${uuid} has conflicting ancestry`) - } - nodes.set(uuid, { - parentUuid, - sessionId, - lineIndex: existing?.lineIndex ?? index, - disallowedLeaf - }) - if ( - input.tip === 'file-tail' && - TRANSCRIPT_TAIL_TYPES.has(row.type) && - row.isSidechain !== true && - row.parent_tool_use_id == null - ) { - tailUuid = uuid - tailLineIndex = index - } - } - - function finish(): ClaudeTranscriptBranchProof { - if (input.tip === 'file-tail' && tailUuid) { - leafUuid = tailUuid - leafMarkerLineIndex = tailLineIndex - } - if (!leafUuid) { - throw new ClaudeTranscriptMarkerMissingError() - } - const leaf = nodes.get(leafUuid) - if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) { - throw transcriptError('marker leaf is missing from the session graph') - } - if (leaf.lineIndex > leafMarkerLineIndex) { - throw transcriptError('marker precedes its leaf record') - } - const previousLeafUuid = input.previousLeafUuid - if (!previousLeafUuid) { - proveMainLineAncestry(nodes, leafUuid, input.providerSessionId) - // Reject reordered rows: append-only ancestry requires each parent to precede its children. - proveAppendOrder(nodes) - return { leafUuid, relation: 'initial' } - } - const previous = nodes.get(previousLeafUuid) - if (!previous) { - throw new ClaudeTranscriptPreviousCursorMissingError() - } - if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) { - throw transcriptError('previous cursor is not on the main transcript') - } - // Validate the old cursor too; it may itself descend through a disallowed sidechain. - proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId) - if (leafUuid === previousLeafUuid) { - proveAppendOrder(nodes) - return { leafUuid, relation: 'same' } - } - const visited = new Set() - let cursor: string | null = leafUuid - for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { - if (visited.has(cursor)) { - throw transcriptError('cycle in parentUuid ancestry') - } - visited.add(cursor) - const node = nodes.get(cursor) - if (!node || node.sessionId !== input.providerSessionId) { - throw transcriptError(`missing ancestor ${cursor}`) - } - if (node.disallowedLeaf) { - throw transcriptError(`ancestor ${cursor} is not on the main transcript`) - } - cursor = node.parentUuid - if (cursor === previousLeafUuid) { - proveAppendOrder(nodes) - return { leafUuid, relation: 'descendant' } - } - } - if (cursor !== null) { - throw transcriptError('ancestry exceeds the bounded proof limit') - } - throw transcriptError('latest marker is on a sibling branch') - } - - /** Call after finish(); a broken walk must throw because empty means nothing followed the anchor. */ - function ancestryChain(leafUuid: string, anchorUuid: string): string[] { - const chain: string[] = [] - let cursor: string | null = leafUuid - for (let depth = 0; cursor !== null && cursor !== anchorUuid; depth += 1) { - if (depth >= MAX_CLAUDE_TRANSCRIPT_ANCESTRY || !nodes.has(cursor)) { - throw transcriptError(`ancestry chain does not reach anchor ${anchorUuid}`) - } - chain.push(cursor) - cursor = nodes.get(cursor)?.parentUuid ?? null - } - if (cursor !== anchorUuid) { - throw transcriptError(`ancestry chain does not reach anchor ${anchorUuid}`) - } - return chain - } -} - -export { ClaudeTranscriptMarkerMissingError, createBranchProof } -export type { BranchProofInput } diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts deleted file mode 100644 index ccd9ca435cb..00000000000 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Pin one descriptor and size so ancestry proof and replay read the same append-only snapshot. - -import { open } from 'node:fs/promises' -import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' -import { - ClaudeTranscriptMarkerMissingError, - ClaudeTranscriptPreviousCursorMissingError, - ClaudeTranscriptTailIncompleteError, - createBranchProof, - type BranchProofInput, - type ClaudeTranscriptBranchProof -} from './claude-transcript-branch-graph' - -export { - ClaudeTranscriptPreviousCursorMissingError, - ClaudeTranscriptTailIncompleteError -} from './claude-transcript-branch-graph' -export type { ClaudeTranscriptBranchProof } from './claude-transcript-branch-graph' - -function nonEmptyString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null -} - -type AncestryRecord = Record - -export type ClaudeTranscriptBranchAncestry = { - proof: ClaudeTranscriptBranchProof - /** Leaf first, anchor excluded. */ - chain: string[] -} - -type AncestryInput = Omit & { - ancestryAnchorUuid: string - /** First occurrence of each chain UUID, in file order. */ - onAncestorRecord: (record: AncestryRecord, uuid: string) => void -} - -function createAncestryReplay( - chain: readonly string[], - onRecord: (record: AncestryRecord, uuid: string) => void -): (line: string) => void { - const pending = new Set(chain) - return (line) => { - if (!line.trim()) { - return - } - let parsed: unknown - try { - parsed = JSON.parse(line) - } catch { - // The proof pass already rejected malformed lines in this snapshot. - return - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The parsed value is a non-array object checked above. - const record = parsed as AncestryRecord - const uuid = nonEmptyString(record.uuid) - // Deletion enforces first-occurrence identity. - if (uuid && pending.delete(uuid)) { - onRecord(record, uuid) - } - } -} - -export function proveClaudeTranscriptBranchFromJsonl( - input: BranchProofInput & { contents: string } -): ClaudeTranscriptBranchProof { - const proof = createBranchProof(input) - const lines = input.contents.split('\n') - for (const [index, line] of lines.entries()) { - proof.add(line, index, index < lines.length - 1) - } - return proof.finish() -} - -type PinnedTranscriptLines = () => AsyncGenerator<{ line: string; terminated: boolean }> - -/** Retry the whole attempt once on growth; both passes must use the newly proven snapshot. */ -async function runPinnedTranscriptPasses( - transcriptPath: string, - maxRecordBytes: number | undefined, - attempt: (readLines: PinnedTranscriptLines) => Promise -): Promise { - const handle = await open(transcriptPath, 'r') - try { - let size = (await handle.stat()).size - let refreshed = false - while (true) { - const pinned = size - const readLines: PinnedTranscriptLines = async function* () { - if (pinned === 0) { - return - } - const stream = handle.createReadStream({ start: 0, end: pinned - 1, autoClose: false }) - yield* splitTranscriptStreamLines(stream, maxRecordBytes) - } - try { - return await attempt(readLines) - } catch (error) { - if ( - !(error instanceof ClaudeTranscriptMarkerMissingError) && - !(error instanceof ClaudeTranscriptPreviousCursorMissingError) && - !(error instanceof ClaudeTranscriptTailIncompleteError) - ) { - throw error - } - if (refreshed) { - throw new ClaudeTranscriptTailIncompleteError() - } - const nextSize = await handle.stat().then( - (current) => current.size, - () => size - ) - if (nextSize <= size) { - throw error - } - size = nextSize - refreshed = true - } - } - } finally { - await handle.close() - } -} - -/** Prove before replay so growth retries cannot emit records twice; retain graph identities, not bodies. */ -export async function replayClaudeTranscriptBranchAncestry( - input: AncestryInput & { transcriptPath: string; maxRecordBytes?: number } -): Promise { - return runPinnedTranscriptPasses( - input.transcriptPath, - input.maxRecordBytes, - async (readLines) => { - const builder = createBranchProof({ ...input, tip: 'file-tail' }) - let index = 0 - for await (const record of readLines()) { - builder.add(record.line, index++, record.terminated) - } - const proof = builder.finish() - const chain = builder.ancestryChain(proof.leafUuid, input.ancestryAnchorUuid) - if (chain.length > 0) { - const replay = createAncestryReplay(chain, input.onAncestorRecord) - for await (const record of readLines()) { - replay(record.line) - } - } - return { proof, chain } - } - ) -} - -export function replayClaudeTranscriptBranchAncestryFromJsonl( - input: AncestryInput & { contents: string } -): ClaudeTranscriptBranchAncestry { - const builder = createBranchProof({ ...input, tip: 'file-tail' }) - const lines = input.contents.split('\n') - for (const [index, line] of lines.entries()) { - builder.add(line, index, index < lines.length - 1) - } - const proof = builder.finish() - const chain = builder.ancestryChain(proof.leafUuid, input.ancestryAnchorUuid) - const replay = createAncestryReplay(chain, input.onAncestorRecord) - for (const line of lines) { - replay(line) - } - return { proof, chain } -} diff --git a/src/main/claude/claude-transcript-branch-streaming.test.ts b/src/main/claude/claude-transcript-branch-streaming.test.ts deleted file mode 100644 index 3de7744ab97..00000000000 --- a/src/main/claude/claude-transcript-branch-streaming.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { FileHandle } from 'node:fs/promises' -import type * as FsPromises from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -type ReaderState = { - path: string - opens: number - closes: number - handles: FileHandle[] - bytesRead: number - statErrorOn: number - readError: boolean - afterStat: (() => Promise) | null -} - -const state = vi.hoisted((): ReaderState => ({ - path: '', - opens: 0, - closes: 0, - handles: [], - bytesRead: 0, - statErrorOn: 0, - readError: false, - afterStat: null -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const fs = await importOriginal() - return { - ...fs, - open: async (path: string, flags: string) => { - const handle = await fs.open(path, flags) - if (path !== state.path) { - return handle - } - state.opens++ - state.handles.push(handle) - let statsRead = 0 - return { - stat: async () => { - statsRead++ - if (statsRead === state.statErrorOn) { - throw new Error('Injected stat failure') - } - const snapshot = await handle.stat() - const afterStat = state.afterStat - state.afterStat = null - await afterStat?.() - return snapshot - }, - createReadStream: (options: Parameters[0]) => { - const stream = handle.createReadStream(options) - stream.once('end', () => { - state.bytesRead += stream.bytesRead - }) - if (state.readError) { - queueMicrotask(() => stream.destroy(new Error('Injected read failure'))) - } - return stream - }, - close: async () => { - await handle.close() - state.closes++ - } - } - } - } -}) - -import { appendFile, mkdtemp, rename, rm, truncate, unlink, writeFile } from 'node:fs/promises' -import { - ClaudeTranscriptPreviousCursorMissingError, - ClaudeTranscriptTailIncompleteError, - 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` -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 = '' -let replayed: string[] = [] -const replay = (anchorUuid = 'root') => - replayClaudeTranscriptBranchAncestry({ - transcriptPath: state.path, - providerSessionId: 'provider', - 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, - closes: 0, - handles: [], - bytesRead: 0, - statErrorOn: 0, - readError: false, - afterStat: null - }) - await writeFile(state.path, SOURCE) -}) - -afterEach(async () => { - try { - expect(state.closes).toBe(state.opens) - for (const handle of state.handles) { - // Check the handle itself because descriptor numbers can be reused. - expect(handle.fd).toBe(-1) - await expect(handle.stat()).rejects.toMatchObject({ code: 'EBADF' }) - } - } finally { - await rm(directory, { recursive: true, force: true }) - } -}) - -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 = - comment + - row('root', null, { message: 'x'.repeat(2 * 1024 * 1024) }) + - CHILD + - marker('child').trimEnd() - await writeFile(state.path, contents) - const fromString: string[] = [] - expect(await replay()).toEqual( - replayClaudeTranscriptBranchAncestryFromJsonl({ - contents, - providerSessionId: 'provider', - previousLeafUuid: 'root', - ancestryAnchorUuid: 'root', - onAncestorRecord: (_record, uuid) => fromString.push(uuid) - }) - ) - expect(replayed).toEqual(fromString) - expect(replayed).toEqual(['child']) -}) - -it.each([ - ['malformed middle', `{"broken":\n${SOURCE}`, false], - ['unterminated malformed tail', `${SOURCE}{"broken":`, true], - ['terminated malformed tail', `${SOURCE}{"broken":\n`, false] -] as const)( - 'preserves error classification for %s and closes the file', - async (_name, contents, incomplete) => { - await writeFile(state.path, contents) - const result = read() - await (incomplete - ? expect(result).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError) - : expect(result).rejects.not.toBeInstanceOf(ClaudeTranscriptTailIncompleteError)) - } -) - -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: 'same' }) - expect(state.bytesRead).toBe(Buffer.byteLength(SOURCE)) -}) - -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, '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, anchor) => { - await writeFile(state.path, contents) - state.afterStat = () => appendFile(state.path, growth) - expect((await read(anchor)).leafUuid).toBe(anchor) - expect(state.opens).toBe(1) - } -) - -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') -}) - -it('preserves the typed static missing-cursor error for existing root reproof', async () => { - await expect(read('absent')).rejects.toBeInstanceOf(ClaudeTranscriptPreviousCursorMissingError) -}) - -it.each([ - ['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, anchor, message) => { - await writeFile(state.path, contents) - state.afterStat = () => appendFile(state.path, CHILD) - await expect(read(anchor)).rejects.toThrow(message) - } -) - -it('does not infer growth from a failed second stat', async () => { - await writeFile(state.path, '') - state.afterStat = () => appendFile(state.path, SOURCE) - state.statErrorOn = 2 - await expect(read()).rejects.toThrow('missing last-prompt marker') -}) - -it('keeps reading the original handle after pathname replacement', async () => { - state.afterStat = async () => { - await rename(state.path, `${state.path}.original`) - await writeFile(state.path, ROOT + CHILD + marker('child')) - } - expect((await read()).leafUuid).toBe('root') -}) - -it('does not use a replacement file to establish growth', async () => { - await writeFile(state.path, '') - state.afterStat = async () => { - await rename(state.path, `${state.path}.original`) - await writeFile(state.path, ROOT + CHILD + marker('child')) - } - await expect(read()).rejects.toThrow('missing last-prompt marker') -}) - -it('can finish an opened file after unlink', async () => { - state.afterStat = () => unlink(state.path) - expect((await read()).leafUuid).toBe('root') -}) - -it('does not infer growth from truncation', async () => { - state.afterStat = () => truncate(state.path, 0) - await expect(read()).rejects.toThrow('missing last-prompt marker') -}) - -it.each(['stat', 'read'] as const)('awaits closure after a %s failure', async (failure) => { - state.statErrorOn = failure === 'stat' ? 1 : 0 - state.readError = failure === 'read' - await expect(read()).rejects.toThrow(`Injected ${failure} failure`) -}) - -it('completes a record appended after the first observed extent without caller retry', async () => { - const contents = ROOT + marker('root').slice(0, -5) - await writeFile(state.path, contents) - state.afterStat = () => appendFile(state.path, marker('root').slice(-5)) - expect((await read()).leafUuid).toBe('root') - expect(state.opens).toBe(1) -}) - -it('validates the entire refreshed prefix instead of accepting a malformed repair', async () => { - 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 () => { - 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(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 deleted file mode 100644 index 3dfd2cf2723..00000000000 --- a/src/main/claude/claude-transcript-marker-branch-proof.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -// Live replay uses the file tail; these marker rules apply when no eligible tail row exists. - -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 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-crash-boundary.spec.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts index 8af47885e68..870c2d02559 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts @@ -1,4 +1,6 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// The crash boundary: the host wrote a submission row, dispatched, and died +// before it learned whether the provider took the message. Replay keeps the message, once, in +// doubt: it is neither re-sent nor guessed undelivered. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -17,11 +19,6 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/str import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { digestPayload } from './journal-payload-bounds' -import { - reconcileSubmissions, - type ProviderHistoryItem, - type ProviderHistoryWindow -} from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -68,27 +65,6 @@ export function registerJournalCrashBoundaryTests(): void { }) } - function history(input: { - itemId: string - clientId: string | null - text: string - ordinal: number - }): ProviderHistoryItem { - return { - providerItemId: input.itemId, - clientMessageId: input.clientId, - payloadFingerprint: digestPayload(input.text), - identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal } - } - } - - function window( - items: ProviderHistoryItem[], - overrides: Partial = {} - ): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } - } - beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-')) clock = 1_000 @@ -100,7 +76,7 @@ export function registerJournalCrashBoundaryTests(): void { }) describe('crash between provider accept and journal commit', () => { - it('reconciles the echo into the existing bubble instead of duplicating it', async () => { + it('keeps a crash-cut send as one bubble in doubt, neither re-sent nor called undelivered', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -108,44 +84,20 @@ export function registerJournalCrashBoundaryTests(): void { body: userMessage('deploy the thing'), fence: 1 }) - // Crash after provider acceptance, before durable dispatch confirmation. + // Host dies here: the provider may have taken it, but no dispatch row was written. const restarted = await open() expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) await restarted.markPendingSubmissionsUnknown(2) - expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') - expect(restarted.submissions()[0]?.recovered).toBe(true) - - const [outcome] = reconcileSubmissions({ - submissions: restarted.submissions(), - history: window([ - history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) - - if (outcome?.outcome !== 'accepted') { - throw new Error('expected the echoed submission to reconcile as accepted') - } - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'accepted', - providerIdentity: outcome.identity, - fence: 2, + expect(restarted.submissions()[0]).toMatchObject({ + dispatchState: 'unknown', recovered: true }) - // Provider echoes must reuse the reconciled identity and existing message bubble. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { - fence: 2, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - const items = restarted.snapshot().items - expect(items).toHaveLength(1) - expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) - expect(restarted.receiptFor('cm_1')?.providerItemId).toBe( - agentJournalItemKey(outcome.identity) - ) + // Outlived by its writer, so no reader reports it as still working. + expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) + expect(restarted.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('cm_1') + ]) }) it('retires an ack timeout on restart without changing its delivery verdict', async () => { @@ -229,42 +181,7 @@ export function registerJournalCrashBoundaryTests(): void { }) }) - it('reports a rejected submission as never delivered, and never re-sends it', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('never landed'), - body: userMessage('never landed'), - fence: 1 - }) - - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - const [outcome] = reconcileSubmissions({ - submissions: restarted.submissions(), - history: window([]) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'rejected', - reason: 'not_delivered' - }) - - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: 2, - recovered: true - }) - expect(restarted.snapshot().items).toHaveLength(1) - expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') - expect(restarted.receiptFor('cm_1')).toBeNull() - }) - - it('survives replay of an already-reconciled journal without changing the answer', async () => { + it('survives replay of an already-settled journal without changing the answer', async () => { const journal = await open() await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -283,9 +200,6 @@ export function registerJournalCrashBoundaryTests(): void { const reopened = await open() expect(reopened.snapshot()).toEqual(settled) expect(reopened.pendingSubmissions()).toHaveLength(0) - expect( - reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) }) - ).toEqual([]) }) it('keeps the receipt across a reopen', async () => { @@ -310,179 +224,4 @@ export function registerJournalCrashBoundaryTests(): void { ) }) }) - - describe('reconciliation matching', () => { - const submissions = [ - { - clientMessageId: 'cm_1', - fence: 1, - payloadFingerprint: digestPayload('same text'), - dispatchState: 'unknown' as const, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - }, - { - clientMessageId: 'cm_2', - fence: 1, - payloadFingerprint: digestPayload('same text'), - dispatchState: 'unknown' as const, - providerItemId: null, - reason: null, - submittedAt: 2, - resolvedAt: null - } - ] - - it('matches each submission to its own echo when the provider carries client ids', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }), - history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }), - expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' }) - ]) - }) - - it('refuses to guess between two identical payloads with no id to tell them apart', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), - history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 }) - ]) - }) - expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) - expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) - }) - - it('does not assign one matching item to the first of two identical sends', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }) - ]) - }) - - expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) - expect(outcomes.map((outcome) => ('reason' in outcome ? outcome.reason : null))).toEqual([ - 'ambiguous_match', - 'ambiguous_match' - ]) - }) - - it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), - history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) - }) - - it('never matches on text alone when the fingerprint disagrees', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([ - { - providerItemId: 'item-1', - clientMessageId: null, - payloadFingerprint: digestPayload('different payload, same rendered text'), - identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } - } - ]) - }) - expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) - }) - - it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), - expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) - ]) - }) - - it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => { - const [outcome] = reconcileSubmissions({ - submissions: [ - { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) } - ], - history: window([ - // Raw provider IDs may change while identity-derived keys remain stable. - history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' }) - }) - - it('never hands one provider item to two submissions', () => { - const outcomes = reconcileSubmissions({ - submissions: [ - { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }, - submissions[1]! - ], - // Two passes claiming one provider item must not create duplicate message bubbles. - history: window([ - history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), - expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) - ]) - }) - - it('stays unknown when the history boundary cannot be trusted', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([], { boundaryConsistent: false }) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'unknown', - reason: 'history_boundary_inconsistent' - }) - }) - - it('stays unknown while a turn is still running', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([], { turnInFlight: true }) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'unknown', - reason: 'turn_in_flight' - }) - }) - - it('leaves settled submissions alone', () => { - expect( - reconcileSubmissions({ - submissions: [ - { - ...submissions[0]!, - dispatchState: 'accepted', - providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) - }, - { ...submissions[1]!, dispatchState: 'rejected' } - ], - history: window([]) - }) - ).toEqual([]) - }) - }) } diff --git a/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts index 245c598ec76..7fc293d6c32 100644 --- a/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts @@ -4,7 +4,6 @@ import { registerJournalCrashBoundaryTests } from './journal-crash-boundary.spec import { registerJournalEpochReplacementTests } from './journal-epoch-replacement.spec' import { registerJournalItemProvenanceTests } from './journal-item-provenance.spec' import { registerJournalLifecycleBatchAppenderTests } from './journal-lifecycle-batch-appender.spec' -import { registerJournalRestartReconciliationTests } from './journal-restart-reconciliation.spec' import { registerJournalUnknownContentTests } from './journal-unknown-content.spec' import { registerJournalUnknownRowKindTests } from './journal-unknown-row-kind.spec' import { registerJournalStopAndResumeRowsTests } from './journal-stop-and-resume-rows.spec' @@ -16,7 +15,6 @@ describe('journal-crash-boundary', registerJournalCrashBoundaryTests) describe('journal-epoch-replacement', registerJournalEpochReplacementTests) describe('journal-item-provenance', registerJournalItemProvenanceTests) describe('journal-lifecycle-batch-appender', registerJournalLifecycleBatchAppenderTests) -describe('journal-restart-reconciliation', registerJournalRestartReconciliationTests) describe('journal-unknown-content', registerJournalUnknownContentTests) describe('journal-unknown-row-kind', registerJournalUnknownRowKindTests) describe('journal-stop-and-resume-rows', registerJournalStopAndResumeRowsTests) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts deleted file mode 100644 index e4c9de6c74f..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { digestPayload } from './journal-payload-bounds' -import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' -import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -export function registerJournalRestartReconciliationTests(): void { - const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('provider-1', null) - } - - function claudeIdentity(uuid: string): AgentJournalItemIdentity { - return { provider: 'claude', sessionId: 'provider-1', uuid } - } - - let root: string - let clock = 1_000 - - function tick(): number { - clock += 1 - return clock - } - - function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - } - - const journals = createTrackedJournalOpener() - - async function open() { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) - } - - function history(uuid: string, text: string): ProviderHistoryItem { - return { - providerItemId: uuid, - clientMessageId: null, - payloadFingerprint: digestPayload(text), - identity: claudeIdentity(uuid) - } - } - - function window( - items: ProviderHistoryItem[], - overrides: Partial = {} - ): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } - } - - async function reopenAfterCrash( - body: AgentJournalMessageItem = userMessage('deploy the thing'), - text = 'deploy the thing' - ) { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload(text), - body, - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - return restarted - } - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-restart-reconcile-')) - clock = 1_000 - }) - - afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - describe('reconcileJournalSubmissionsAgainstHistory', () => { - it('settles a message found in provider history as accepted on its provider identity', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-1', 'deploy the thing')]) - }) - - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('accepted') - expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) - }) - - it('settles a message provably absent from history as rejected: not_delivered', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - // Released clients display reason text directly; preserve wording beside typed facts. - expect(submission?.reason).toBe( - agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) - .reason - ) - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) - expect(submission && classifyDispatchRejection(submission)).toEqual({ - category: 'undelivered', - verdict: null, - kind: 'notDelivered' - }) - }) - - it('leaves a submission unknown while the provider reports a turn in flight', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { turnInFlight: true }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a submission unknown when the history boundary is inconsistent', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { boundaryConsistent: false }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { - const journal = await reopenAfterCrash( - { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'look at this' }, - { type: 'image-ref', path: '/tmp/shot.png' } - ] - }, - 'look at this' - ) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a send that carried stopped history unknown: its provider copy leads with that history', async () => { - const journal = await open() - for (const clientMessageId of ['stopped', 'cm_1']) { - await journal.appendSubmission({ - clientMessageId, - payloadFingerprint: digestPayload(clientMessageId), - body: userMessage(clientMessageId), - fence: 1, - handoverRecorded: true - }) - } - await journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'pending', - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE, - carries: ['stopped'] - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual([]) - expect(restarted.submission('cm_1')?.dispatchState).toBe('unknown') - expect(restarted.stoppedHistoryCarried().get('cm_1')).toEqual(['stopped']) - }) - - it('leaves multi-block text sends unknown because Claude joins them before recording history', async () => { - const journal = await reopenAfterCrash( - { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'second' } - ] - }, - 'first\nsecond' - ) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-1', 'first\nsecond')]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('does not let an item the journal already committed stand in for a new send', async () => { - const journal = await open() - await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) - }) - - expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') - }) - - it('does not let an older accepted provider item stand in for a new identical send', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_old', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_old', - state: 'accepted', - providerIdentity: claudeIdentity('uuid-old'), - fence: 1 - }) - await journal.appendSubmission({ - clientMessageId: 'cm_new', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'accepted', - 'rejected' - ]) - expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) - }) - - it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { - const journal = await open() - for (const id of ['cm_1', 'cm_2']) { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: digestPayload('ping'), - body: userMessage('ping'), - fence: 1 - }) - } - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-1', 'ping'), history('uuid-2', 'ping')]) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'unknown', - 'unknown' - ]) - }) - }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts deleted file mode 100644 index 511d364890d..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Provider history may narrow unknown delivery, never trigger automatic redelivery. -// User retries rotate IDs; unproven outcomes retain their original uncertainty. - -import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { - AgentJournalMessageItem, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../shared/agent-session-journal-item-key' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentSessionJournal } from './journal-store' -import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' - -/** Attachment paths are absent from provider history; only text-only sends can prove non-delivery. */ -function comparableBody(body: AgentJournalMessageItem | undefined): boolean { - return ( - body?.kind === 'message' && - body.role === 'user' && - // Conversation commands leave no provider user-message item to compare. - body.command === undefined && - body.blocks.length === 1 && - body.blocks[0]?.type === 'text' && - body.blocks[0].text.trim().length > 0 - ) -} - -function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmission[] { - const snapshot = journal.snapshot() - const { items, submissions } = agentSessionCurrentContextRows( - snapshot.items, - snapshot.submissions - ) - const bodies = new Map(items.map((item) => [item.itemId, item.body])) - return submissions.filter((submission) => { - if ( - (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') || - isQueuedAgentJournalSubmission(submission) || - // Missing history cannot prove queued handoffs unsent. - submission.queuedMessageId !== undefined || - // Carried stopped-history prefixes change fingerprints; absence cannot prove non-delivery. - journal.stoppedHistoryCarried().has(submission.clientMessageId) - ) { - return false - } - const body = bodies.get(agentJournalSubmissionKey(submission.clientMessageId)) - return comparableBody(body?.kind === 'message' ? body : undefined) - }) -} - -/** Exclude committed history so uncertain sends cannot match earlier identical messages. */ -function unseenHistory( - journal: AgentSessionJournal, - history: ProviderHistoryWindow -): ProviderHistoryWindow { - const snapshot = journal.snapshot() - const committed = new Set(snapshot.items.map((item) => item.itemId)) - // Accepted echoes render under Orca IDs; also exclude their aliased provider identities. - for (const submission of snapshot.submissions) { - if (submission.dispatchState === 'accepted' && submission.providerItemId) { - committed.add(submission.providerItemId) - } - } - return { - ...history, - // Identity-free items rely on the adapter's after-last-acceptance history window. - items: history.items.filter( - (item) => item.identity === undefined || !committed.has(agentJournalItemKey(item.identity)) - ) - } -} - -export async function reconcileJournalSubmissionsAgainstHistory(input: { - journal: AgentSessionJournal - fence: number - history: ProviderHistoryWindow -}): Promise { - const submissions = comparableSubmissions(input.journal) - if (submissions.length === 0) { - return [] - } - const settled: string[] = [] - for (const outcome of reconcileSubmissions({ - submissions, - history: unseenHistory(input.journal, input.history) - })) { - if (outcome.outcome === 'unknown') { - console.warn('[journal-reconcile] submission left unconfirmed:', { - clientMessageId: outcome.clientMessageId, - reason: outcome.reason, - fence: input.fence - }) - continue - } - await input.journal.resolveDispatch( - outcome.outcome === 'accepted' - ? { - clientMessageId: outcome.clientMessageId, - state: 'accepted', - providerIdentity: outcome.identity, - fence: input.fence, - recovered: true - } - : { - clientMessageId: outcome.clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: input.fence, - recovered: true - } - ) - settled.push(outcome.clientMessageId) - } - return settled -} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts deleted file mode 100644 index dff812131ef..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ /dev/null @@ -1,173 +0,0 @@ -// Crash recovery resolves durable submissions against history without automatic redelivery. -// Match echoed IDs, adopted keys, then unique fingerprints; identical sends must never collapse. - -import type { - AgentJournalItemIdentity, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' - -export type ProviderHistoryItem = { - /** Claim each provider item once; derive journal keys from identity instead of unstable raw IDs. */ - providerItemId: string - /** Null when the provider does not echo client IDs. */ - clientMessageId: string | null - /** Match only a unique unclaimed payload candidate. */ - payloadFingerprint: string | null - /** Absent identity preserves the submission's existing Orca key. */ - identity?: AgentJournalItemIdentity -} - -export type ProviderHistoryWindow = { - items: readonly ProviderHistoryItem[] - /** Only a complete, consistent history boundary can prove non-delivery by absence. */ - boundaryConsistent: boolean - /** An in-flight turn makes absence inconclusive. */ - turnInFlight: boolean -} - -export type SubmissionReconciliation = - | { - clientMessageId: string - outcome: 'accepted' - providerItemId: string - identity: AgentJournalItemIdentity - } - | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } - | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } - -export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED - -export type SubmissionUnknownReason = - | 'history_boundary_inconsistent' - | 'turn_in_flight' - | 'ambiguous_match' - -/** Run stronger matches across all submissions first; weak matches cannot steal exact-ID candidates. */ -export function reconcileSubmissions(input: { - submissions: readonly AgentJournalSubmission[] - history: ProviderHistoryWindow -}): SubmissionReconciliation[] { - const unsettled = input.submissions.filter( - (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' - ) - const claimed = new Set() - const matched = new Map() - const ambiguous = new Set() - - claimBy( - unsettled, - input.history.items, - claimed, - matched, - (submission, item) => - // Adopted keys survive provider raw-ID renumbering. - Boolean(submission.providerItemId) && - item.identity !== undefined && - submission.providerItemId === agentJournalItemKey(item.identity) - ) - claimBy( - unsettled, - input.history.items, - claimed, - matched, - (submission, item) => - Boolean(item.clientMessageId) && item.clientMessageId === submission.clientMessageId - ) - - // Match fingerprint groups together: one candidate cannot distinguish identical submissions. - const submissionsByFingerprint = new Map() - for (const submission of unsettled) { - if (matched.has(submission.clientMessageId) || !submission.payloadFingerprint) { - continue - } - const sameFingerprint = submissionsByFingerprint.get(submission.payloadFingerprint) ?? [] - sameFingerprint.push(submission) - submissionsByFingerprint.set(submission.payloadFingerprint, sameFingerprint) - } - for (const [fingerprint, fingerprintSubmissions] of submissionsByFingerprint) { - const candidates = input.history.items.filter( - (item) => !claimed.has(item.providerItemId) && item.payloadFingerprint === fingerprint - ) - if (fingerprintSubmissions.length === 1 && candidates.length === 1) { - const [submission] = fingerprintSubmissions - const [only] = candidates - claimed.add(only!.providerItemId) - matched.set(submission!.clientMessageId, only!) - continue - } - if (candidates.length > 0) { - // Equal payloads without IDs cannot establish which submission arrived. - for (const submission of fingerprintSubmissions) { - ambiguous.add(submission.clientMessageId) - } - } - } - - return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history)) -} - -function claimBy( - submissions: readonly AgentJournalSubmission[], - items: readonly ProviderHistoryItem[], - claimed: Set, - matched: Map, - matches: (submission: AgentJournalSubmission, item: ProviderHistoryItem) => boolean -): void { - for (const submission of submissions) { - if (matched.has(submission.clientMessageId)) { - continue - } - const item = items.find((candidate) => { - return !claimed.has(candidate.providerItemId) && matches(submission, candidate) - }) - if (item) { - claimed.add(item.providerItemId) - matched.set(submission.clientMessageId, item) - } - } -} - -function resolveOne( - submission: AgentJournalSubmission, - matched: Map, - ambiguous: Set, - history: ProviderHistoryWindow -): SubmissionReconciliation { - const item = matched.get(submission.clientMessageId) - if (item) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'accepted', - providerItemId: item.providerItemId, - identity: item.identity ?? { provider: 'orca', clientMessageId: submission.clientMessageId } - } - } - if (ambiguous.has(submission.clientMessageId)) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'ambiguous_match' - } - } - if (!history.boundaryConsistent) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'history_boundary_inconsistent' - } - } - if (history.turnInFlight) { - return { - clientMessageId: submission.clientMessageId, - outcome: 'unknown', - reason: 'turn_in_flight' - } - } - return { - clientMessageId: submission.clientMessageId, - outcome: 'rejected', - reason: DISPATCH_REJECTED_NOT_DELIVERED - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index bd7b06c0a37..ce093ca07ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -568,8 +568,6 @@ describe('what an earlier host process left behind', () => { }) it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => { - const providerHistoryWindow = vi.fn(async () => null) - adapterExtras = { providerHistoryWindow } await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) @@ -587,8 +585,6 @@ describe('what an earlier host process left behind', () => { expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) - // History reconciliation requires a won lease. - expect(providerHistoryWindow).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts index 2a29cb39a71..c82d6d1d2a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts @@ -2,7 +2,17 @@ import { expect, it, vi } from 'vitest' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { HOST_TEST_NOW, HOST_TEST_THREAD } from './structured-agent-session-host-test-data' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestAttachParams, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { journalIdentityFor } from './structured-agent-session-attach' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { CALLER, attach, @@ -45,3 +55,49 @@ it("creates a chat while another saved chat's owner check fails", async () => { expect(store.getRecord(peerId)?.lease.unreconciled).toBe(true) expect(store.getRecord('new-chat')?.lease.claimStatus).toBe('live') }) + +it('keeps a send a crash cut off as sent when its chat reattaches', async () => { + const record = await attach() + const prior = hostTestState() + if (!record) { + throw new Error('the initial attach did not reserve its chat') + } + await prior.host.close(HOST_TEST_SESSION, 'evict') + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor(record, attachParams()), + database: openTestJournalHostDatabase(prior.root) + }) + await journal.appendSubmission({ + clientMessageId: 'crashed-send', + payloadFingerprint: digestPayload('deploy the thing'), + body: hostTestMessage('deploy the thing'), + fence: record.lease.runtimeFence + }) + await journal.close() + + const store = await openTestAgentSessionRecordStore(prior.root) + await store.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: HOST_TEST_NOW + 1 + }) + const host = new StructuredAgentSessionHost({ ...prior.host.deps, store }) + replaceHostTestState({ store, host }) + + expect( + await host.attach( + CALLER, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? null) + ) + ).toMatchObject({ + ok: true, + value: { + unconfirmedClientMessageIds: ['crashed-send'], + page: { + submissions: [ + { clientMessageId: 'crashed-send', dispatchState: 'unknown', recovered: true } + ] + } + } + }) + expect(prior.dispatch).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 1da097e02af..f9c4e921859 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,9 +1,6 @@ import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionAccountHome, - AgentSessionExecutionLocation -} from '../../../shared/agent-session-record' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' import type { @@ -171,13 +168,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi readOptionRestoreFailures = (sessionId: string): readonly string[] => this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] - providerHistoryWindow = (input: { - identity: AgentSessionJournalIdentity - accountHome: AgentSessionAccountHome - }) => - this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ?? - Promise.resolve(null) - copyConversation: NonNullable = (input) => { const { adapter } = this.requireAgent(input.identity) if (!adapter.copyConversation) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 489537af860..ca8b07ff650 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -33,7 +33,6 @@ export type { import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionStartupAttempt, StructuredAgentSessionAcquisitionReceipt @@ -270,11 +269,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & fence: number }): Promise readOptionRestoreFailures?(sessionId: string): readonly string[] - /** Only consistent history boundaries can prove non-delivery; omitted windows leave unsettled sends unknown. */ - providerHistoryWindow?(input: { - identity: AgentSessionJournalIdentity - accountHome: AgentSessionAccountHome - }): Promise /** Copy the provider conversation without changing the chat’s ID; omit for CLI-based forks. */ copyConversation?(input: { identity: AgentSessionJournalIdentity diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8a09050911f..e4f18317540 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -21,7 +21,6 @@ import { admitAttachOrRefuse, attachJournal, classifyStoreFailure, - journalIdentityFor, reserveRequestFor, type AgentSessionAttachAuthority, type AgentSessionAttachParams, @@ -47,8 +46,6 @@ import { withAgentSessionCreatePhase, type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { readProviderHistoryWindow } from './structured-agent-session-provider-history-window' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -141,7 +138,6 @@ export async function performAttach( let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false - let providerHistoryWindow: ProviderHistoryWindow | null = null const lock = input.lock ?? heldStructuredAgentSessionAttachLock(input.acquireSignal) const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } @@ -194,15 +190,6 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - // Read history before spawn; a live child makes absent prompts inconclusive for non-delivery. - providerHistoryWindow = await lock.wait( - readProviderHistoryWindow({ - adapter: input.adapter, - identity: journalIdentityFor(record, params), - accountHome: record.accountHome, - ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) - }) - ) if (agentSessionLeaseAdmitsWriter(record.lease)) { // Keep the existing live owner. } else if (replayed && reserved.operationRow.outcome.status === 'succeeded') { @@ -269,10 +256,7 @@ export async function performAttach( await input.beforeJournalOpen?.() const journal = await attachJournal({ record: acquired, - params, - adapter: input.adapter, - openConversation: input.openConversation, - providerHistoryWindow + openConversation: input.openConversation }) await importAdoptedTranscript(params, journal, acquired, preparedTranscript.items) await input.onAttached(journal, acquisitionGeneration, owner) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts deleted file mode 100644 index 632b0481c20..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Verify attach wires history reconciliation without redelivery. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' -import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { - attachJournal, - journalIdentityFor, - type AgentSessionAttachParams -} from './structured-agent-session-attach' - -const RECORD = agentSessionRecordFixture() - -const PARAMS = { - envelope: { - sessionId: RECORD.sessionId, - clientOperationId: 'op-1', - expectedRuntimeFence: RECORD.lease.runtimeFence, - payloadFingerprint: 'fp' - }, - location: RECORD.location, - provider: 'claude', - agent: 'claude', - accountHome: RECORD.accountHome, - runtimeKind: 'native' -} as unknown as AgentSessionAttachParams - -const IDENTITY = journalIdentityFor(RECORD, PARAMS) - -let root: string -const journals = createTrackedJournalOpener() - -function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -function window(overrides: Partial = {}): ProviderHistoryWindow { - return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides } -} - -/** Spy on every send route so reconciliation cannot silently redeliver. */ -function adapterWith(providerHistoryWindow?: () => Promise): { - adapter: StructuredAgentSessionAdapter - dispatch: ReturnType -} { - const dispatch = vi.fn() - const adapter = { - dispatch, - ...(providerHistoryWindow ? { providerHistoryWindow } : {}) - } as unknown as StructuredAgentSessionAdapter - return { adapter, dispatch } -} - -async function crashedJournal(clientMessageId = 'cm_1', text = 'deploy the thing') { - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: root - }) - await journal.appendSubmission({ - clientMessageId, - payloadFingerprint: digestPayload(text), - body: userMessage(text), - fence: RECORD.lease.runtimeFence - }) - await journal.close() -} - -async function attach(adapter: StructuredAgentSessionAdapter) { - const attached = await attachJournal({ - record: RECORD, - params: PARAMS, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - adapter - }) - journals.track(attached.journal) - return attached -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-attach-reconcile-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('attachJournal restart reconciliation', () => { - it('settles a provably undelivered submission and stops reporting it unconfirmed', async () => { - await crashedJournal() - const { adapter, dispatch } = adapterWith(async () => window()) - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual([]) - const submission = attached.journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('gives a send the provider never received no verdict and no listing', async () => { - await crashedJournal() - const { adapter } = adapterWith(async () => window()) - - const attached = await attach(adapter) - - // A crash-stranded send is unconfirmed, not a failure. - const { items, submissions } = attached.journal.snapshot() - expect( - projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) - ).toMatchObject({ summary: { status: null }, latestRequest: null }) - }) - - it('still reports a submission unconfirmed when the window cannot decide it', async () => { - await crashedJournal() - const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) - expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') - expect(dispatch).not.toHaveBeenCalled() - }) - - it('leaves the crash boundary untouched for an adapter that reports no history', async () => { - await crashedJournal() - const { adapter } = adapterWith() - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) - expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('does not fail the attach when reading provider history throws', async () => { - await crashedJournal() - const { adapter } = adapterWith(async () => { - throw new Error('transcript unreadable') - }) - - const attached = await attach(adapter) - - expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) - expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a message the open conversation still has queued alone (W4′e)', async () => { - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: root - }) - await journal.appendSubmission({ - clientMessageId: 'queued', - payloadFingerprint: digestPayload('still queued'), - body: userMessage('still queued'), - fence: RECORD.lease.runtimeFence, - handoverRecorded: true - }) - // Empty consistent history can prove handed-over sends undelivered. - const { adapter, dispatch } = adapterWith(async () => window()) - - const attached = await attachJournal({ - record: RECORD, - params: PARAMS, - adapter, - openConversation: async () => journal - }) - - expect(attached.journal).toBe(journal) - expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'pending' }) - expect(journal.submissions()[0]?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 64e98a47e48..93d2d138b04 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -36,9 +36,6 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' /** Clients cannot assert spawn tokens, claim keys or owner death; only host observations grant authority. */ @@ -152,79 +149,38 @@ export function journalIdentityFor( export type AttachedJournal = { journal: AgentSessionJournal + /** Submissions the crash boundary left `unknown`; kept on the wire for older peers. */ unconfirmedClientMessageIds: string[] } -/** Reconcile pre-spawn history under the won lease; never resend or touch unhanded queues. */ +/** + * The conversation's journal, opened — and its crash boundary settled — by the conversation's own + * open. A send the boundary could only doubt stays `unknown`: whether the provider took it is never + * inferred from provider history, since absence from a log Orca does not own proves nothing. + */ export async function attachJournal(input: { record: AgentSessionRecord - params: AgentSessionAttachParams - adapter: StructuredAgentSessionAdapter + /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise - /** Pre-spawn history; null means unavailable, omitted permits lazy reads for direct callers. */ - providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { - const identity = journalIdentityFor(input.record, input.params) - const fence = input.record.lease.runtimeFence const journal = await input.openConversation(input.record) - const settled = await reconcileAgainstProviderHistory({ - adapter: input.adapter, - identity, - journal, - fence, - accountHome: input.record.accountHome, - ...(Object.hasOwn(input, 'providerHistoryWindow') - ? { history: input.providerHistoryWindow } - : {}) - }) return { journal, unconfirmedClientMessageIds: journal .submissions() - .filter( - (entry) => - entry.dispatchState === 'unknown' && - entry.recovered === true && - !settled.includes(entry.clientMessageId) - ) + .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered === true) .map((entry) => entry.clientMessageId) } } -/** History reads are best effort; journal write failures must propagate to the handle owner. */ -async function reconcileAgainstProviderHistory(input: { - adapter: StructuredAgentSessionAdapter - identity: AgentSessionJournalIdentity - journal: AgentSessionJournal - fence: number - accountHome: AgentSessionAccountHome - history?: ProviderHistoryWindow | null -}): Promise { - let history = input.history - if (history === undefined) { - if (!input.adapter.providerHistoryWindow) { - return [] - } - try { - history = await input.adapter.providerHistoryWindow({ - identity: input.identity, - accountHome: input.accountHome - }) - } catch { - return [] - } - } - if (!history) { - return [] - } - return reconcileJournalSubmissionsAgainstHistory({ - journal: input.journal, - fence: input.fence, - history - }) -} - -/** Adoption starts at fence 1; resumed links may be elided when identical proof is replayed. */ +/** + * The first link of an adopting session's chain. + * + * `adopted` is the only origin besides `created` a chain will accept at its head, and it is the + * honest one here: this session did not create the conversation. The adapter appends its own + * `resumed` link once the provider proves the same identity root — or, when it proves the identical + * handle at the same fence, the validator elides that as a retry and this link stays the head. + */ const ADOPTED_HANDLE_FENCE = 1 function adoptedProviderHandleLink( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index de4b3a0c805..6f755b6cff1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -1,5 +1,14 @@ -// Opening starts no provider. Settle crash-stranded work before readers: handed-over sends become -// unknown, provably unhanded personal text becomes cards. History reconciliation later requires a lease. +// The one way a conversation's journal becomes open on this host: for a send, for a reader, and +// for an attach that finds none open. +// +// A damaged journal fails the open, which refuses it as unloadable. It marks what an earlier host +// process handed over and left unanswered as in doubt, and settles what it left running — the +// crash boundary. That +// needs no lease, and the doubt is final: whether the provider took such a row is never guessed. A +// row an earlier process accepted and never handed over (it quit or crashed first) is settled here +// too, before any reader, command or child sees it: a person's message is kept as a card, the rest +// rejected (`journal-unsent-send-hold.ts`). The cards then wait for the chat's next turn +// (`queued-message-pause.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -90,7 +99,7 @@ export async function openStructuredAgentSessionConversationJournal( const identity = journalIdentityFor(record, params) const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase }) try { - // Provider history may resolve uncertainty only after winning the lease. + // A handed-over row found here stays doubt: it reads as sent, and nothing narrows it later. await journal.markPendingSubmissionsUnknown(fence) } catch (error) { deps.logger.warn('marking pending sends unknown on open failed', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index dc97dc6f89a..6958d2070f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -65,8 +65,6 @@ function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachPar it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => { const params = await adoptedParams() - const history = vi.fn(async () => null) - rig.host.deps.adapter.providerHistoryWindow = history expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true, value: { @@ -83,7 +81,6 @@ it('imports adoption at rest and resumes its original provider identity after it { origin: 'adopted', mintedAtFence: 1, handle: { nativeId: THREAD } } ]) expect(rig.adapter.acquire).not.toHaveBeenCalled() - expect(history).not.toHaveBeenCalled() await rm(join(rig.root, 'rollout.jsonl')) expect( await rig.host.create(CALLER, { @@ -101,10 +98,6 @@ it('imports adoption at rest and resumes its original provider identity after it }) }) ) - expect(history).toHaveBeenCalledOnce() - expect(history.mock.invocationCallOrder[0]).toBeLessThan( - rig.adapter.acquire.mock.invocationCallOrder[0] - ) expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts deleted file mode 100644 index 7c086ea3baa..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-history-window.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Sample before acquiring a child; once running, absent prompts cannot prove non-delivery. - -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' - -export async function readProviderHistoryWindow(input: { - adapter: StructuredAgentSessionAdapter - identity: AgentSessionJournalIdentity - accountHome: AgentSessionRecord['accountHome'] - ownerAlreadyAdmitted: boolean -}): Promise { - const read = input.adapter.providerHistoryWindow - if (!read) { - return null - } - let history: ProviderHistoryWindow | null - try { - history = await read({ identity: input.identity, accountHome: input.accountHome }) - } catch { - return null - } - // An already-live lease may belong to an unindexed child; retain uncertainty. - return history && input.ownerAlreadyAdmitted ? { ...history, turnInFlight: true } : history -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index da1322d14c8..53e6e11ba20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -50,6 +50,8 @@ export type QueuedMessageTestRig = Awaited number + /** Teardown records its restart offers, which `restartOffers` lists. */ recoveryCapsule?: true location?: AgentSessionExecutionLocation /** Defaults to indeterminate. */ @@ -70,7 +72,7 @@ export async function createQueuedMessageTestRig( journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', - now: () => NOW, + now: options.now ?? (() => NOW), ...(options.probeOwner ? { probeOwner: options.probeOwner } : {}), ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}), ...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-queue-idle.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-queue-idle.test.ts new file mode 100644 index 00000000000..53c8403f727 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-queue-idle.test.ts @@ -0,0 +1,75 @@ +import { expect, it } from 'vitest' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + createQueuedMessageTestRig, + eventually +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_IDLE_MS as IDLE_MS } from './structured-agent-session-idle-sweep' + +it('keeps a recovered handoff consumed through natural idle unload and a history reopen', async () => { + let now = HOST_TEST_NOW + const rig = await createQueuedMessageTestRig({ + restartable: true, + now: () => now, + idleSweep: { idleMs: IDLE_MS, intervalMs: 2_000_000_000 } + }) + try { + const first = await rig.workingSend() + await rig.send('handed-over card', 'queue-if-active').result + await rig.send('still waiting', 'queue-if-active').result + const cards = await rig.drafts() + const consumedId = cards[0]!.messageId + const waitingId = cards[1]!.messageId + await rig.settleAccepted(first, 'initial') + await eventually(async () => + expect((await rig.handoff(consumedId))?.handedOverAt).toBeDefined() + ) + const handoffId = await rig.handoffId(consumedId) + const dispatches = rig.dispatch.mock.calls.length + // Simulate process loss without a graceful close settling the handed-over row. + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + rig.crashRestartHostProcess() + const read = async () => { + const result = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + if (!result.ok) { + throw new Error('history refused') + } + return result.page + } + const recovered = await read() + expect(recovered.submissions.find((row) => row.clientMessageId === handoffId)).toMatchObject({ + queuedMessageId: consumedId, + dispatchState: 'unknown', + recovered: true + }) + expect(recovered.queuedMessages?.map((card) => card.messageId)).toEqual([waitingId]) + const messages = () => + read().then((page) => projectStructuredAgentSessionMessages(page.items, [], page.submissions)) + const bubble = (await messages()).filter( + (message) => message.id === agentJournalSubmissionKey(handoffId) + ) + expect(bubble).toHaveLength(1) + expect(bubble[0]?.unsent).toBeUndefined() + expect(bubble[0]?.queued).toBeUndefined() + now += IDLE_MS + 1 + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(await rig.deleteQueued(waitingId)).toMatchObject({ ok: true }) + now += IDLE_MS + 1 + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + const reopened = await read() + expect(reopened.queuedMessages ?? []).toEqual([]) + expect( + (await messages()).filter((message) => message.id === agentJournalSubmissionKey(handoffId)) + ).toEqual(bubble) + expect(rig.dispatch).toHaveBeenCalledTimes(dispatches) + } finally { + await rig.dispose() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index c1768a0e333..dd2640ddbf0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -57,7 +57,7 @@ export async function startAgent(state: { export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' | 'queued-cards' = 'turn', - historyBoundaryConsistent = true, + /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ probeOwner: NonNullable = async () => ({ outcome: 'pid-absent' }), @@ -145,19 +145,7 @@ export async function interruptedRestart( agents, logger: log.logger, store, - adapter: { - ...adapter(), - closeSession, - ...(work === 'submission' || work === 'send-after-reply' - ? { - providerHistoryWindow: async () => ({ - items: [], - boundaryConsistent: historyBoundaryConsistent, - turnInFlight: false - }) - } - : {}) - }, + adapter: { ...adapter(), closeSession }, journalDatabase: openTestJournalHostDatabase(previous.root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 6b252dc2fe2..a494c56c225 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -35,7 +35,7 @@ const SUPERSEDED = 'agent_session_restart_work_superseded' const COLD_START = { timeout: 10_000 } async function offered(work: 'turn' | 'submission' = 'turn') { - const state = await interruptedRestart(work, false) + const state = await interruptedRestart(work) expect(await state.host.restartResume.list()).toHaveLength(1) return state } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 424bd4afa46..80ba77eaa0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -87,7 +87,7 @@ it('reports a failed attribution note without an installed error sink or private it.each(['turn', 'submission'] as const)( 'does not continue a marked %s after the user submits new work without a provider echo', async (work) => { - const { host, dispatch } = await interruptedRestart(work, false) + const { host, dispatch } = await interruptedRestart(work) expect(await host.restartResume.list()).toHaveLength(1) await startAgent(hostTestState()) dispatch.mockResolvedValueOnce({ state: 'admitted' }) @@ -99,21 +99,22 @@ it.each(['turn', 'submission'] as const)( } ) -it('still continues a chat whose last send acquisition proves was never delivered', async () => { +// Teardown judged the chat working. Whether the agent got the cut-off send is never guessed: it stays +// sent (STA-7416), and the continuation asks the agent to check what finished. +it('keeps a send a restart cut off as sent, and still continues its chat', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'notDelivered' } - }) + const [cut] = (await host.journalSnapshot(SESSION)).submissions + expect(cut).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(cut).not.toHaveProperty('rejection') expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) }) it('offers and continues a send made after an earlier completed turn', async () => { - const { host, dispatch, marker } = await interruptedRestart('send-after-reply', false) + const { host, dispatch, marker } = await interruptedRestart('send-after-reply') expect(marker?.work.kind).toBe('submission') const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) @@ -468,7 +469,8 @@ it('logs teardown capsule publication failure and still releases the provider', // Renderer guidance keys on refusal code; retain details beside it. it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => { - const { host, store } = await interruptedRestart('turn', true, async () => ({ + // The terminal agent that holds the claim is still running, so nothing may take it over. + const { host, store } = await interruptedRestart('turn', async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] })) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts index f118de2f3f9..eb2c35b2a6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts @@ -27,7 +27,6 @@ function codexOnAnotherTransport(): StructuredAgentRegistry { it('does not offer to resume a chat whose agent this build no longer drives, and keeps the offer', async () => { const { host, root, acquire } = await interruptedRestart( - undefined, undefined, undefined, codexOnAnotherTransport() @@ -44,7 +43,6 @@ it('does not offer to resume a chat whose agent this build no longer drives, and it('lists a recorded failure of such a chat as not retryable', async () => { const { host, root, marker } = await interruptedRestart( - undefined, undefined, undefined, codexOnAnotherTransport() @@ -73,7 +71,6 @@ it('lists a recorded failure of such a chat as not retryable', async () => { it('keeps such a chat readable and refuses to start its agent', async () => { const { host, store, acquire } = await interruptedRestart( - undefined, undefined, undefined, codexOnAnotherTransport() diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-cut.ts b/src/main/native-chat/agent-session-wire/structured-rewind-cut.ts index 5d0601cdcc4..4c69304fce6 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-cut.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-cut.ts @@ -1,17 +1,12 @@ // Derive cuts from the journal’s retained old epoch so admission and recovery agree without copying history. -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { isAgentSessionRewindTarget } from '../../../shared/agent-session-rewind' +import { savedRewindKeys, isAgentSessionRewindTarget } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' -import { - agentJournalSubmissionKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, - AgentJournalRenderItem, - AgentJournalSubmission + AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' @@ -39,9 +34,9 @@ export function structuredRewindCut( const items = floor ? snapshot.items.filter((item) => item.sequence > floor.sequence) : snapshot.items - const { providerKey, omitted } = submissionKeys(snapshot.submissions) + const { providerKey, omitted, nativeIdentity } = savedRewindKeys(snapshot.submissions) const selected = items.find((item) => item.itemId === target.itemId) - const key = selected ? parseAgentJournalItemKey(providerKey(selected.itemId)) : null + const key = selected ? nativeIdentity(selected.itemId) : null if (key?.provider === 'claude' && target.provider === 'claude') { // Messages before an earlier fork still cut at their original position. const boundary = items.findIndex((item) => item.itemId === target.itemId) @@ -130,26 +125,7 @@ function lastListedTurn( } export function providerItemIdOf(journal: AgentSessionJournal, itemId: string): string { - return submissionKeys(journal.snapshot().submissions).providerKey(itemId) -} - -// Rebuilt epochs lose submission links, so accepted messages must adopt provider keys. -function submissionKeys(submissions: readonly AgentJournalSubmission[]): { - providerKey: (itemId: string) => string - /** Queued messages survive separately; rejected messages never ran. */ - omitted: ReadonlySet -} { - const providerKeys = new Map() - const omitted = new Set() - for (const submission of submissions) { - const key = agentJournalSubmissionKey(submission.clientMessageId) - if (isQueuedAgentJournalSubmission(submission) || submission.dispatchState === 'rejected') { - omitted.add(key) - } else if (submission.dispatchState === 'accepted' && submission.providerItemId) { - providerKeys.set(key, submission.providerItemId) - } - } - return { providerKey: (itemId) => providerKeys.get(itemId) ?? itemId, omitted } + return savedRewindKeys(journal.snapshot().submissions).providerKey(itemId) } /** Child rows belong to their enclosing main turn, never their child-thread turn ID. */ diff --git a/src/main/opencode/opencode-acp-stored-messages.test.ts b/src/main/opencode/opencode-acp-stored-messages.test.ts deleted file mode 100644 index 43ca17f8a5d..00000000000 --- a/src/main/opencode/opencode-acp-stored-messages.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import Database from '../sqlite/sync-database' -import { readOpenCodeTranscriptPage } from '../native-chat/transcript-opencode-sqlite-query' -import { - openCodeStoredUserMessagesReader, - type OpenCodeTranscriptPageReader -} from './opencode-acp-stored-messages' - -const roots: string[] = [] -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -/** An `opencode.db` under a fresh XDG data home, built by `schema`; returns that data home. */ -function dataHomeWith(schema: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-opencode-stored-')) - roots.push(root) - mkdirSync(join(root, 'opencode')) - const db = new Database(join(root, 'opencode', 'opencode.db')) - db.exec(schema) - db.close() - return root -} - -/** The bounded reader in-process, standing in for its worker. */ -const readInProcess: OpenCodeTranscriptPageReader = async (args) => readOpenCodeTranscriptPage(args) - -function readFrom(dataHome: string) { - return openCodeStoredUserMessagesReader(readInProcess)({ - env: { HOME: dataHome, USERPROFILE: dataHome, XDG_DATA_HOME: dataHome }, - providerSessionId: 'ses_1', - signal: new AbortController().signal - }) -} - -const OPENCODE_2_TABLES = `CREATE TABLE session_v2 (id TEXT PRIMARY KEY); - CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, - data TEXT, time_created INTEGER, time_updated INTEGER);` - -describe('OpenCode stored user messages', () => { - it("reads the database under the chat's own home, and only its user messages", async () => { - const readPage = vi.fn(async () => ({ - items: [ - { - rowid: 1, - fingerprint: 'a', - message: { id: 'msg_1', role: 'user', blocks: [], timestamp: 5, source: 'transcript' } - }, - { - rowid: 2, - fingerprint: 'b', - message: { - id: 'msg_2', - role: 'assistant', - blocks: [], - timestamp: 6, - source: 'transcript' - } - } - ], - hasMore: false, - beforeMessageRowId: 1 - })) - const home = join('/', 'child-home') - const read = openCodeStoredUserMessagesReader(readPage) - await expect( - read({ - env: { HOME: home, USERPROFILE: home }, - providerSessionId: 'ses_1', - signal: new AbortController().signal - }) - ).resolves.toEqual([{ id: 'msg_1', blocks: [], createdAt: 5 }]) - expect(readPage).toHaveBeenCalledWith( - expect.objectContaining({ - dbPath: join(home, '.local', 'share', 'opencode', 'opencode.db'), - sessionId: 'ses_1' - }), - expect.anything() - ) - }) - - it('reads a 2.x session from its live tables, not the frozen 1.x copy an upgrade left', async () => { - const dataHome = dataHomeWith(`${OPENCODE_2_TABLES} - CREATE TABLE session (id TEXT PRIMARY KEY); - CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, - data TEXT); - CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); - INSERT INTO session VALUES ('ses_1'); - INSERT INTO message VALUES ('old', 'ses_1', 1, 1, '{"role":"user"}'); - INSERT INTO part VALUES ('p', 'old', 'ses_1', 1, '{"type":"text","text":"frozen"}'); - INSERT INTO session_v2 VALUES ('ses_1'); - INSERT INTO session_message VALUES - ('u1', 'ses_1', 'user', 1, '{"text":"live prompt"}', 50, 50), - ('a1', 'ses_1', 'assistant', 2, '{"content":[{"type":"text","text":"reply"}]}', 60, 60);`) - await expect(readFrom(dataHome)).resolves.toEqual([ - { id: 'opencode:u1', blocks: [{ type: 'text', text: 'live prompt' }], createdAt: 50 } - ]) - }) - - it('finds nothing in a 2.x database whose message table it does not know', async () => { - const dataHome = dataHomeWith(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); - CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, body TEXT); - INSERT INTO session_v2 VALUES ('ses_1');`) - await expect(readFrom(dataHome)).resolves.toBeNull() - }) -}) diff --git a/src/main/opencode/opencode-acp-stored-messages.ts b/src/main/opencode/opencode-acp-stored-messages.ts deleted file mode 100644 index 8a2beff5641..00000000000 --- a/src/main/opencode/opencode-acp-stored-messages.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { homedir } from 'node:os' -import { OPENCODE_TRANSCRIPT_MAX_WINDOW } from '../../shared/opencode-transcript-page-limit' -import type { AcpStoredUserMessagesReader } from '../acp/acp-recovery-history' -import { resolveOpenCodeDatabasePath } from './opencode-data-directory' -import type { readOpenCodeTranscriptPageViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' - -export type OpenCodeTranscriptPageReader = typeof readOpenCodeTranscriptPageViaWorker - -/** The latest user messages of an OpenCode session, read from the database its pinned account - * selects, through the existing bounded reader: 2.x's `session_v2` rows first, else 1.x's. */ -export function openCodeStoredUserMessagesReader( - readPage?: OpenCodeTranscriptPageReader -): AcpStoredUserMessagesReader { - return async ({ env, providerSessionId, signal }) => { - // The child's own home, which its account may name, not this process's. - const home = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() - const dbPath = resolveOpenCodeDatabasePath(env, home) - if (!dbPath) { - return null - } - const read = - readPage ?? - (await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn')) - .readOpenCodeTranscriptPageViaWorker - const page = await read( - { dbPath, sessionId: providerSessionId, limit: OPENCODE_TRANSCRIPT_MAX_WINDOW }, - signal - ) - if (!page) { - return null - } - return page.items.flatMap(({ message }) => - message.role === 'user' && message.timestamp !== null - ? [{ id: message.id, blocks: message.blocks, createdAt: message.timestamp }] - : [] - ) - } -} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 49dea582552..1f496b2e71c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -87,12 +87,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) it('leaves offers it was not shown when the host runs an agent an older desktop cannot show', async () => { - const { host, root, marker } = await interruptedRestart( - undefined, - undefined, - undefined, - withPilot() - ) + const { host, root, marker } = await interruptedRestart(undefined, undefined, withPilot()) setStructuredAgentSessionHost(host) const capsule = new AgentSessionRecoveryCapsule(root) // An offer whose chat this launch cannot read names no agent the desktop was shown. diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index c0541d70172..569caec180b 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -147,7 +147,6 @@ function createAcpAdapter( }) return new AcpStructuredSessionAdapter({ spec, - readJournal, resolveLaunch: composeStructuredLaunch(definition, sources, part), readLaunch: composeStructuredLaunchRead(definition, sources, part), dataDirectory: deps.stateDirectory, diff --git a/src/main/runtime/structured-chat-worker-start-unknown.test.ts b/src/main/runtime/structured-chat-worker-start-unknown.test.ts new file mode 100644 index 00000000000..5e4a64d0b47 --- /dev/null +++ b/src/main/runtime/structured-chat-worker-start-unknown.test.ts @@ -0,0 +1,76 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +import { expect, it, vi } from 'vitest' +import * as sessionCreate from './rpc/methods/structured-agent-session-create' +import { attachParams, providerFaults } from './structured-chat-coordinator-fake-codex-fixture' +import { + COORDINATOR, + call, + codex, + coordinatorRunAndTask, + db, + host, + openChat, + runtime +} from './structured-chat-coordinator-mail-rig.test-fixture' + +it('keeps a minted worker start unknown after one preamble handover without an echo', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ supported: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: placement reads only the workspace identity in this rig. + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'workspace-1', + repoId: 'repo' + } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch-mode selection reads only getSettings on this store double. + vi.spyOn(runtime, 'requireStore').mockReturnValue({ + getSettings: () => ({ + experimentalNativeChat: true, + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never) + vi.spyOn(sessionCreate, 'createStructuredAgentSessionForWorktree').mockImplementation( + async (args) => { + const attached = await host.attach(args.caller, attachParams(args.envelope.sessionId)) + if (!attached.ok) { + throw new Error('scripted worker attach refused') + } + return { + ...attached, + value: { ...attached.value, sessionId: args.envelope.sessionId, fence: attached.fence } + } + } + ) + providerFaults.dieBeforeEveryEcho = true + const receipt = await call( + 'orchestration.workerStart', + { + task: taskId, + agent: 'codex', + worktree: 'current' + }, + { sessionId: COORDINATOR } + ) + + expect(receipt).toMatchObject({ + state: 'outcome_unknown', + lastError: expect.stringMatching(/not acknowledged/) + }) + expect(JSON.stringify(receipt.nextCommands)).toContain('worker-show') + expect(JSON.stringify(receipt.nextCommands)).toContain('worker-abandon') + const dispatchId = String(receipt.dispatchId) + expect(db.getWorkerDispatch(dispatchId)?.state).toBe('start_unknown') + const worker = codex.connections.at(-1)! + const sessionId = db.getDispatchContextById(dispatchId)?.assignee_orca_session_id + if (!sessionId) { + throw new Error('minted worker session missing') + } + const snapshot = await host.journalSnapshot(sessionId) + expect(snapshot.submissions).toHaveLength(1) + expect(snapshot.submissions[0]).toMatchObject({ dispatchState: 'unknown' }) + expect(worker.turns).toHaveLength(1) + expect(providerFaults.turnStarts).toBe(1) +}) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts index c9db705c03c..0a8a3bb91bd 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts @@ -236,6 +236,12 @@ it('keeps preparation and runtime failures from starting a terminal', async () = }) const ensure = vi.spyOn(runtime, 'ensureStructuredAgentSessionHost') for (const [failure, expected] of [ + [ + new Error( + 'Could not confirm this conversation was saved. Try again after the agent responds.' + ), + { message: 'chat_fork_source_unavailable' } + ], [ new Error('This chat has no saved conversation to copy.'), { message: 'chat_fork_source_unavailable' } diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts index fa16461f46a..023c37116dd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts @@ -1,8 +1,23 @@ import { useCallback, type RefObject } from 'react' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { useStructuredAgentSessionRewindBlockedReason } from './StructuredAgentSessionStatusBridge' import type { NativeChatComposerHandle } from './NativeChatComposer' -import type { NativeChatRewindHost } from './use-native-chat-rewind' +import type { AgentSessionRewindReason } from '../../../../shared/agent-session-rewind' + +export type NativeChatRewindSurface = { + disabledReason: string | null + eligibleItemIds?: ReadonlySet + request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise +} + +/** What the pane hosting a structured session tells its rewind. */ +export type NativeChatRewindHost = { + /** The host's in-doubt latch, from its status feed. */ + hostBlockedReason?: AgentSessionRewindReason | null + onMessageReturned?: () => void + isVisible?: boolean +} /** What a pane hands its session's rewind: the host's in-doubt latch, its visibility, and where a * returned message lands. `focusComposer` is the same focus the pane's other give-backs use. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts index ad63590e33e..023a586f4d6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' import { translate } from '@/i18n/i18n' import { + savedRewindKeys, isAgentSessionRewindTarget, type AgentSessionRewindReason, type AgentSessionRewindResult, @@ -24,18 +25,13 @@ import type { } from './use-structured-agent-session-mutate' import { returnMessageToComposer } from './structured-agent-session-message-hand-back' import { nativeChatRewindOffered } from './native-chat-rewind-eligibility' +import type { NativeChatRewindSurface } from './use-native-chat-rewind-host' import { agentSessionContextFloorSequence, latestAgentSessionContextClearSequence } from '../../../../shared/agent-session-context-clear' import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' -export type NativeChatRewindSurface = { - disabledReason: string | null - eligibleItemIds?: ReadonlySet - request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise -} - /** How long a confirmed rewind waits for its new conversation before letting go. */ export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000 @@ -81,12 +77,13 @@ function rewindFailureReason(failure: AgentSessionWriteFailure): string | undefi } function isRewindTarget(input: RewindInput, itemId: string): boolean { - const floor = rewindContextFloor(input) const opener = turnOpeners(input.state.items) + const keys = savedRewindKeys(input.state.submissions) return input.state.items.some( (item) => item.itemId === itemId && - item.sequence > floor && + item.sequence > rewindContextFloor(input) && + (keys.nativeIdentity(itemId) !== null || !keys.hasSubmission(itemId)) && isAgentSessionRewindTarget(item, (turn) => opener.get(turn)) ) } @@ -304,15 +301,18 @@ export function useNativeChatRewind(input: RewindInput) { const floor = rewindContextFloor(input) const eligibleItemIds = useMemo(() => { const opener = turnOpeners(input.state.items) + const keys = savedRewindKeys(input.state.submissions) return new Set( input.state.items .filter( (item) => - item.sequence > floor && isAgentSessionRewindTarget(item, (turn) => opener.get(turn)) + item.sequence > floor && + (keys.nativeIdentity(item.itemId) !== null || !keys.hasSubmission(item.itemId)) && + isAgentSessionRewindTarget(item, (turn) => opener.get(turn)) ) .map((item) => item.itemId) ) - }, [floor, input.state.items]) + }, [floor, input.state.items, input.state.submissions]) // Rows re-render only when this changes; none get the action where the provider can never rewind. const surface = useMemo( () => (offered ? { disabledReason, request, eligibleItemIds } : undefined), @@ -321,10 +321,4 @@ export function useNativeChatRewind(input: RewindInput) { return { request, admitsSend, unlessBlocked, surface, disabledReason, pending, blockedRef } } -/** What the pane hosting a structured session tells its rewind. */ -export type NativeChatRewindHost = { - /** The host's in-doubt latch, from its status feed. */ - hostBlockedReason?: AgentSessionRewindReason | null - onMessageReturned?: () => void - isVisible?: boolean -} +export type { NativeChatRewindHost, NativeChatRewindSurface } from './use-native-chat-rewind-host' diff --git a/src/shared/agent-session-rewind.ts b/src/shared/agent-session-rewind.ts index 9838f54bf1a..fcf924a0be8 100644 --- a/src/shared/agent-session-rewind.ts +++ b/src/shared/agent-session-rewind.ts @@ -4,8 +4,12 @@ import { AgentJournalProducerLinkageFields, AgentJournalTurnScopeSchema } from './agent-session-journal-schemas' -import { parseAgentJournalItemKey } from './agent-session-journal-item-key' -import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from './agent-session-journal-item-key' +import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' export const AGENT_SESSION_REWIND_REASONS = [ @@ -85,6 +89,31 @@ export const AgentSessionRewindRecordSchema = z.object({ export type AgentSessionRewindRecord = z.infer export type AgentSessionRewindFork = NonNullable +// Accepted messages keep Orca's key until a rewind rebuilds the epoch with provider keys. +export function savedRewindKeys(submissions: readonly AgentJournalSubmission[]) { + const providerKeys = new Map() + const omitted = new Set() + for (const submission of submissions) { + const key = agentJournalSubmissionKey(submission.clientMessageId) + providerKeys.set(key, key) + if (isQueuedAgentJournalSubmission(submission) || submission.dispatchState === 'rejected') { + omitted.add(key) + } else if (submission.dispatchState === 'accepted' && submission.providerItemId) { + providerKeys.set(key, submission.providerItemId) + } + } + const providerKey = (itemId: string) => providerKeys.get(itemId) ?? itemId + return { + providerKey, + omitted, + hasSubmission: (itemId: string) => providerKeys.has(itemId), + nativeIdentity: (itemId: string) => { + const key = parseAgentJournalItemKey(providerKey(itemId)) + return key?.provider === 'claude' || key?.provider === 'codex' ? key : null + } + } +} + /** A message a rewind can return to: a user message that opened its own turn. One sent into a * running turn belongs to that turn, whose provider and journal order it differently, so a rewind * goes back only to whole turns. `turnOpener` reads the message a turn record names as its opener, diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 4001d73d5bf..08a35f3e984 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -335,7 +335,7 @@ export type AgentSessionAttachResult = { sessionId: string fence: number page: AgentSessionHistoryPage - /** Submissions a crash boundary left `unknown` that provider history could not decide. */ + /** Submissions a crash boundary left `unknown`. No client reads it; kept for older peers. */ unconfirmedClientMessageIds: string[] /** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */ tabId?: string diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 991070f1466..a26224f77f2 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -37,8 +37,8 @@ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' * Read only, like the one above. */ export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery' -/** Restart reconciliation found the send absent from a provider history it could trust. Rows - * written before rows carried a fact hold it as their reason; released clients printed it. */ +/** Legacy marker: an older host's restart guess that the send was absent from provider history. + * Read only, like the ones above; released clients printed it. */ export const DISPATCH_REJECTED_NOT_DELIVERED = 'not_delivered' /** True for the internal transport marker, false for a provider's own words. Legacy-reason half diff --git a/tests/e2e/native-chat-rewind-boundary.unit.test.ts b/tests/e2e/native-chat-rewind-boundary.unit.test.ts new file mode 100644 index 00000000000..54dad253a2d --- /dev/null +++ b/tests/e2e/native-chat-rewind-boundary.unit.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../src/shared/agent-session-journal-item-key' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../src/shared/structured-agent-session-reducer' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../src/shared/agent-session-journal-types' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { structuredRewindCut } from '../../src/main/native-chat/agent-session-wire/structured-rewind-cut' +import { useNativeChatRewind } from '../../src/renderer/src/components/native-chat/use-native-chat-rewind' + +const IDENTITY = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('main') +} +const journals = createTrackedJournalOpener() +let root: string +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +it('offers a recovered opener only when its saved native alias arrives, retaining prior history', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rewind-boundary-')) + const options = { identity: IDENTITY, stateDirectory: root } + const journal = await journals.open(options) + const native = (uuid: string) => ({ provider: 'claude' as const, sessionId: 'original', uuid }) + const blocks = [{ type: 'text' as const, text: 'prompt' }] + const body = { kind: 'message' as const, role: 'user' as const, blocks } + const attribution = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(native('earlier'), body, attribution) + await journal.appendItem(native('reply'), { ...body, role: 'assistant' }, attribution) + await journal.appendSubmission({ + clientMessageId: 'lost', + payloadFingerprint: 'lost', + body, + fence: 1 + }) + await journals.closeAll() + const recovered = await journals.open(options) + await recovered.markPendingSubmissionsUnknown(2) + const snapshot = recovered.snapshot() + const target = agentJournalSubmissionKey('lost') + expect(snapshot.submissions.find((row) => row.clientMessageId === 'lost')).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + const state = { + ...EMPTY_STRUCTURED_AGENT_SESSION, + ...snapshot, + epoch: snapshot.cursor.epoch, + status: 'ready' as const, + fence: 2 + } + const props = { + sessionId: IDENTITY.sessionId, + capturePresentation: () => () => true, + state, + support: { supported: true as const }, + blocked: false, + send: vi.fn() + } + const view = renderHook((input) => useNativeChatRewind(input), { initialProps: props }) + try { + expect(view.result.current.surface?.eligibleItemIds).not.toContain(target) + const cursor = recovered.cursor() + expect( + structuredRewindCut(recovered, { itemId: target, provider: 'claude', nativeId: 'forked' }) + ).toBeNull() + expect(recovered.cursor()).toEqual(cursor) + view.rerender({ ...props, state: { ...state, submissions: [] } }) + expect(view.result.current.surface?.eligibleItemIds).toContain(target) + await recovered.acceptLate({ + clientMessageId: 'lost', + state: 'accepted', + providerIdentity: native('genuine'), + fence: 2 + }) + view.rerender({ ...props, state: { ...state, submissions: recovered.snapshot().submissions } }) + expect(view.result.current.surface?.eligibleItemIds).toContain(target) + const cut = structuredRewindCut(recovered, { + itemId: target, + provider: 'claude', + nativeId: 'forked' + }) + expect(cut).toMatchObject({ beforeMessage: { sessionId: 'original', uuid: 'genuine' } }) + expect(cut?.kept.map(({ identity }) => agentJournalItemKey(identity))).toEqual([ + agentJournalItemKey(native('earlier')), + agentJournalItemKey(native('reply')) + ]) + } finally { + view.unmount() + } +})