Files
orca/tests/e2e/cross-version-wire/agent-session-interrupted-tool-downgrade.unit.test.ts
Neil 1c950baf33 refactor(main): group session lifecycle and journal modules by owner (#38638)
* refactor: group structured session modules by owner

* test: retire status history work-count oracle suite

* refactor(main): organize journal storage and projection owners

* Consolidate restart recovery fixture imports

* Consolidate concrete session option model imports
2026-11-06 06:00:08 -08:00

160 lines
6.4 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, test } from 'vitest'
import { codexProviderHandle } from '../../../src/shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
import {
endedRunningAgentJournalToolCall,
interruptedAgentJournalToolCall
} from '../../../src/shared/agent-journal-tool-call-lifecycle'
import type { AgentJournalItemIdentity } from '../../../src/shared/agent-session/journal/agent-session-journal-identity'
import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/store-lifecycle/journal-host-database-test-support'
import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/durable-rows/journal-row-schema'
import type { JournalReadRow } from '../../../src/main/native-chat/agent-session-journal/replay/journal-read-row'
import {
importReleaseCheckoutModule,
materializeReleaseCheckout,
releaseModuleExport
} from './release-checkout'
import { prepareReleaseFixtures } from './release-fixture-setup'
// Pin a release predating endedAs to prove cut-short tool calls still read as failed.
const BASELINE_REF = 'v1.4.218'
const releaseToolModules = prepareReleaseFixtures([BASELINE_REF], async (ref) => {
const checkout = await materializeReleaseCheckout(ref)
return Promise.all(
[
'src/main/native-chat/agent-session-journal/journal-reducer.ts',
'src/shared/agent-session-journal-schemas.ts',
'src/shared/structured-agent-session-projection.ts',
'src/shared/native-chat-tool-run-outcome.ts'
].map((path) => importReleaseCheckoutModule(checkout, path))
)
})
const SESSION = 'session-interrupted-tool'
function call(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
type OldRenderItem = { itemId: string; body: { kind: string; [field: string]: unknown } }
type OldMessage = { blocks: unknown[] } | null
type OldRunOutcome = (
blocks: readonly unknown[],
options: { activeTurnIsWorking?: boolean }
) => { failedCallCount: number; succeeded: boolean }
// Old builds fold input rows; an outcome row here means the probe opened a converted chat by mistake.
function inputRows(rows: readonly JournalReadRow[]): JournalRow[] {
return rows.map((row) => {
if (row.kind === 'outcome') {
throw new Error('expected input rows')
}
return row
})
}
// Loads a real old build, including cold extraction and transforms.
test('an older host and client read a call a stop cut short as the failure they always showed', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-interrupted-tool-downgrade-'))
const journals = createTrackedJournalOpener({ legacyInputs: true })
try {
// This build: a call a stop cut short, with the partial output it left.
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: codexProviderHandle('thread-1')
},
stateDirectory: directory,
now: () => 1_000
})
const cutShort = interruptedAgentJournalToolCall({
kind: 'tool-call',
name: 'shell',
input: { command: 'sleep 20' },
state: 'running',
output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false }
})
await journal.appendItem(call(0), cutShort, { fence: 1, turnScope: { kind: 'thread' } })
// And one an end nothing proved closed, kept so a later proof can still correct it.
const unverified = endedRunningAgentJournalToolCall(
{ kind: 'tool-call', name: 'shell', input: { command: 'sleep 30' }, state: 'running' },
'unverifiable'
)
await journal.appendItem(call(1), unverified, { fence: 1, turnScope: { kind: 'thread' } })
const since = journal.readSince({ epoch: journal.epoch, sequence: 0 })
if (!since.ok) {
throw new Error(`expected rows, got reset ${since.reset}`)
}
const rows = inputRows(since.rows)
const current = journal.snapshot().items
expect(current.map((item) => item.body)).toMatchObject([
{ state: 'failed', endedAs: 'interrupted' },
{ state: 'failed', endedAs: 'unverifiable' }
])
const [reducer, schemas, projection, outcome] = releaseToolModules(BASELINE_REF)
const createState = releaseModuleExport<(sessionId: string, epoch: string) => unknown>(
reducer,
'createJournalReducerState'
)
const applyRow = releaseModuleExport<(state: unknown, row: JournalRow) => void>(
reducer,
'applyJournalRow'
)
const render = releaseModuleExport<(state: unknown) => { items: OldRenderItem[] }>(
reducer,
'renderJournalState'
)
const renderItemSchema = releaseModuleExport<{ parse: (value: unknown) => OldRenderItem }>(
schemas,
'AgentJournalRenderItemSchema'
)
const project = releaseModuleExport<(item: unknown) => OldMessage>(
projection,
'projectStructuredItemToNativeChat'
)
const runOutcome = releaseModuleExport<OldRunOutcome>(outcome, 'nativeChatToolRunOutcome')
const settled = { activeTurnIsWorking: false }
const blocksOf = (items: OldRenderItem[]) =>
items.flatMap((item) => project(item)?.blocks ?? [])
// The older host, after a downgrade, folds the rows it never wrote as failed calls.
const state = createState(SESSION, journal.epoch)
for (const row of rows) {
applyRow(state, row)
}
const folded = render(state).items
expect(folded.map((item) => item.body)).toMatchObject([
{ kind: 'tool-call', state: 'failed' },
{ kind: 'tool-call', state: 'failed' }
])
expect(runOutcome(blocksOf(folded), settled)).toEqual({
failedCallCount: 2,
succeeded: false
})
// The older client reads this host's render items the same way.
const read = current.map((item) => renderItemSchema.parse(item))
expect(runOutcome(blocksOf(read), settled)).toEqual({
failedCallCount: 2,
succeeded: false
})
// Why the fact rides beside `failed` rather than replacing it: that build calls an unknown
// state a success.
const asNewState = renderItemSchema.parse({
...current[0],
body: { ...cutShort, state: 'interrupted', endedAs: undefined }
})
expect(runOutcome(project(asNewState)?.blocks ?? [], settled).succeeded).toBe(true)
} finally {
await journals.closeAll()
rmSync(directory, { recursive: true, force: true })
}
})