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
This commit is contained in:
Brennan Benson
2026-10-24 00:27:01 -07:00
committed by GitHub
parent c2241d44af
commit 6059a5f7fa
50 changed files with 539 additions and 3728 deletions
@@ -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<typeof useMobileSessionTerminalCreateActions> | 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]
@@ -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)
})
-5
View File
@@ -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,
-224
View File
@@ -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()
})
})
-132
View File
@@ -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<string, string>
providerSessionId: string
signal: AbortSignal
}) => Promise<AcpStoredUserMessage[] | null>
// 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<number>()
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<AcpStructuredSessionAdapterDeps, 'readLaunch' | 'readJournal' | 'logger'> & {
spec: Pick<AcpLaunchSpec, 'readStoredUserMessages'>
},
identity: AgentSessionJournalIdentity
): Promise<ProviderHistoryWindow | null> {
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
}
}
@@ -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<AcpStructuredLaunch>
/** 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<AcpStructuredLaunch>
/** Current host arguments before protocol-specific filtering; copies open through the CLI. */
resolveLaunchArgs?: () => Promise<string[]> | string[]
/** Reads without opening the journal. */
readJournal?: (sessionId: string) => JournalLoad | null
/** Protected by this host's instance lock. */
dataDirectory: string
connect: ConnectAcpAgent
@@ -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<StructuredAgentSessionAdapter['providerHistoryWindow']> = (
input
) => readAcpRecoveryHistory(this.deps, input.identity)
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) =>
this.copies.copy(input)
@@ -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<typeof FsPromises>()
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<FileHandle['createReadStream']>[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)
})
})
@@ -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<string, unknown>
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: '<system-reminder>be careful</system-reminder>' }
]),
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'])
})
})
@@ -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<string, unknown>
function stringField(source: unknown, key: string): string | null {
if (!source || typeof source !== 'object') {
return null
}
const value = (source as Record<string, unknown>)[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<string, ProviderHistoryItem>()
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<string, ProviderHistoryItem>,
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<ProviderHistoryWindow | null> {
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<ProviderHistoryWindow> {
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
}
}
@@ -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<void> => drainClaudeObservedExits(this.exits)
/** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */
providerHistoryWindow: NonNullable<StructuredAgentSessionAdapter['providerHistoryWindow']> = (
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,
@@ -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')
})
})
@@ -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<string, TranscriptNode>,
startUuid: string,
providerSessionId: string
): void {
const visited = new Set<string>()
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<string, TranscriptNode>): 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<unknown> = 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<string, TranscriptNode>()
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<string, unknown>
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<string>()
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 }
@@ -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<string, unknown>
export type ClaudeTranscriptBranchAncestry = {
proof: ClaudeTranscriptBranchProof
/** Leaf first, anchor excluded. */
chain: string[]
}
type AncestryInput = Omit<BranchProofInput, 'tip'> & {
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<T>(
transcriptPath: string,
maxRecordBytes: number | undefined,
attempt: (readLines: PinnedTranscriptLines) => Promise<T>
): Promise<T> {
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<ClaudeTranscriptBranchAncestry> {
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 }
}
@@ -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<void>) | 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<typeof FsPromises>()
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<FileHandle['createReadStream']>[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)
})
@@ -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<string> {
const root = await mkdtemp(join(tmpdir(), prefix))
tempRoots.push(root)
return root
}
async function proveMarkerLeafUuid(
transcriptPath: string,
providerSessionId: string,
previousLeafUuid: string | null = null
): Promise<string> {
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'
)
})
})
@@ -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> = {}
): 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([])
})
})
}
@@ -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)
@@ -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> = {}
): 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'
])
})
})
}
@@ -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<string[]> {
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
}
@@ -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<string>()
const matched = new Map<string, ProviderHistoryItem>()
const ambiguous = new Set<string>()
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<string, AgentJournalSubmission[]>()
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<string>,
matched: Map<string, ProviderHistoryItem>,
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<string, ProviderHistoryItem>,
ambiguous: Set<string>,
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
}
}
@@ -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()
})
})
@@ -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()
})
@@ -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<StructuredAgentSessionAdapter['copyConversation']> = (input) => {
const { adapter } = this.requireAgent(input.identity)
if (!adapter.copyConversation) {
@@ -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<StructuredAgentSessionLiveOptions>
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<ProviderHistoryWindow | null>
/** Copy the provider conversation without changing the chat’s ID; omit for CLI-based forks. */
copyConversation?(input: {
identity: AgentSessionJournalIdentity
@@ -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)
@@ -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> = {}): ProviderHistoryWindow {
return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides }
}
/** Spy on every send route so reconciliation cannot silently redeliver. */
function adapterWith(providerHistoryWindow?: () => Promise<ProviderHistoryWindow | null>): {
adapter: StructuredAgentSessionAdapter
dispatch: ReturnType<typeof vi.fn>
} {
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()
})
})
@@ -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<AgentSessionJournal>
/** Pre-spawn history; null means unavailable, omitted permits lazy reads for direct callers. */
providerHistoryWindow?: ProviderHistoryWindow | null
}): Promise<AttachedJournal> {
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<string[]> {
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(
@@ -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', {
@@ -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)
})
@@ -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<ProviderHistoryWindow | null> {
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
}
@@ -50,6 +50,8 @@ export type QueuedMessageTestRig = Awaited<ReturnType<typeof createQueuedMessage
export async function createQueuedMessageTestRig(
options: QueuedRigProviderOptions & {
idleSweep?: { idleMs: number; intervalMs: number }
now?: () => 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) } : {})
@@ -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()
}
})
@@ -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<StructuredAgentSessionHostDeps['probeOwner']> = 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',
@@ -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
}
@@ -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']
}))
@@ -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()
@@ -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<string>
} {
const providerKeys = new Map<string, string>()
const omitted = new Set<string>()
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. */
@@ -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<OpenCodeTranscriptPageReader>(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()
})
})
@@ -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 }]
: []
)
}
}
@@ -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.
@@ -147,7 +147,6 @@ function createAcpAdapter(
})
return new AcpStructuredSessionAdapter({
spec,
readJournal,
resolveLaunch: composeStructuredLaunch(definition, sources, part),
readLaunch: composeStructuredLaunchRead(definition, sources, part),
dataDirectory: deps.stateDirectory,
@@ -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)
})
@@ -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' }
@@ -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<string>
request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise<void>
}
/** 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. */
@@ -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<string>
request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise<void>
}
/** 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<NativeChatRewindSurface | undefined>(
() => (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'
+31 -2
View File
@@ -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<typeof AgentSessionRewindRecordSchema>
export type AgentSessionRewindFork = NonNullable<AgentSessionRewindRecord['providerFork']>
// 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<string, string>()
const omitted = new Set<string>()
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,
+1 -1
View File
@@ -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
@@ -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
@@ -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()
}
})