Files
orca/src/renderer/src/components/native-chat/structured-agent-session-fork-command.test.ts
T
Merge Sim 6a79bfb958 fix(native-chat): make turn forking work, recoverable and bounded
The fork feature was inert. `completedTurnEnd` required a journal row whose
`turnLifecycle.state` is `completed`, and nothing writes one: settlement
TOMBSTONES the running row instead. Every turn therefore looked live and every
fork refused `busy`. Completion is now read as "not the active turn", off the
same projection the chat view reads, and the fixtures that hand-built the
impossible row are replaced with journals the real producers emit.

- Read completion as the absence of a running lifecycle row, via
  `activeStructuredAgentSessionTurnId`; Codex matches by turn id, Claude by the
  turn being the newest one.
- Carry `legacy:` rows into the child instead of throwing. Adoption imports the
  whole prior transcript under that namespace, so forking any turn taken after
  adopting a conversation refused `unsupported`. Parent turn-lifecycle rows are
  dropped with the other host operation-lifecycle rows.
- Give fork a refusal terminal. A failure that provably preceded any provider
  session now settles to `refused` and clears `retained`, instead of stranding
  the record at `attempted` where every later attach threw
  `agent_session_operation_unknown` and the dead prefix was rewritten on every
  lease renewal. An ambiguous outcome still keeps the guard and never makes a
  second provider session.
- Settle an existing epoch rather than replacing the child journal twice when a
  crash lands between the journal transaction and the completion transition.
- Evict the oldest idle client attempt at the cap instead of rejecting, which
  wedged forking for every session, tab and worktree until a restart, and report
  each typed refusal in its own words instead of collapsing them into "wait for
  the conversation to finish". The pre-request capability error now surfaces as
  itself rather than as an unconfirmed outcome.
- Stop a failed fork-support probe from blanking the slash-command menu and the
  per-turn option refresh on a remote target.
- Scan eligibility in one linear pass and skip it entirely while the action is
  unavailable: 2000 items went from 1.43ms to 0.05ms, and a single long turn
  from 16.2ms to 0.09ms.
- Drop six orphan i18n keys a mis-resolved merge resurrected, which failed
  `verify:localization-runtime-catalog`.
2026-09-09 18:40:12 -07:00

127 lines
4.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { call, CapabilityError } = vi.hoisted(() => ({
call: vi.fn(),
CapabilityError: class extends Error {}
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: call,
StructuredAgentSessionCapabilityError: CapabilityError
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { forkStructuredSessionFromTurn } from './structured-agent-session-fork-command'
let id = 0
const input = () => ({
target: { kind: 'local' } as const,
worktree: 'workspace',
agent: 'codex' as const,
source: {
sessionId: `parent-${++id}`,
itemId: 'codex:parent:turn:1',
expectedEpoch: 'epoch',
expectedRuntimeFence: 1
}
})
beforeEach(() => {
call.mockReset()
})
describe('fork create intent replay', () => {
it('replays the exact child id and operation after a lost reply', async () => {
const args = input()
call
.mockRejectedValueOnce(new Error('disconnected'))
.mockResolvedValueOnce({ ok: true, value: { sessionId: 'child' } })
await expect(forkStructuredSessionFromTurn(args)).rejects.toThrow('could not be confirmed')
await forkStructuredSessionFromTurn(args)
expect(call.mock.calls[1]?.[2]).toEqual(call.mock.calls[0]?.[2])
expect(call.mock.calls[0]?.[2]).toMatchObject({
forkFrom: args.source,
envelope: { expectedRuntimeFence: null }
})
})
it('routes the create to the parent execution host and never falls back locally', async () => {
const args = {
...input(),
target: { kind: 'environment', environmentId: 'remote-host' } as const
}
call.mockRejectedValue(new Error('unreachable'))
await expect(forkStructuredSessionFromTurn(args)).rejects.toThrow('could not be confirmed')
expect(call).toHaveBeenCalledTimes(1)
expect(call.mock.calls[0]?.[0]).toEqual(args.target)
})
it('joins simultaneous clicks into one create request', async () => {
let finish!: (value: unknown) => void
call.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const args = input()
const first = forkStructuredSessionFromTurn(args)
const second = forkStructuredSessionFromTurn(args)
expect(second).toBe(first)
finish({ ok: true })
await first
expect(call).toHaveBeenCalledTimes(1)
})
it('allows a fresh attempt after a pre-commit busy refusal', async () => {
const args = input()
call
.mockResolvedValueOnce({ ok: false, refusal: { forkReason: 'busy' } })
.mockResolvedValueOnce({ ok: true })
await expect(forkStructuredSessionFromTurn(args)).rejects.toThrow('Wait for this conversation')
await forkStructuredSessionFromTurn(args)
expect(call.mock.calls[1]?.[2].envelope.sessionId).not.toBe(
call.mock.calls[0]?.[2].envelope.sessionId
)
})
it.each([
['unsupported', 'cannot be forked'],
['history-limit', 'too much history'],
['stale-epoch', 'moved on'],
['invalid-target', 'Could not fork this turn.'],
['outcome-unknown', 'could not be confirmed']
])('reports a %s refusal in its own words', async (forkReason, message) => {
call.mockResolvedValue({ ok: false, refusal: { forkReason } })
await expect(forkStructuredSessionFromTurn(input())).rejects.toThrow(message)
})
it('surfaces the pre-request capability guard instead of an unconfirmed outcome', async () => {
const args = input()
call
.mockRejectedValueOnce(new CapabilityError('Forking requires a newer Orca server.'))
.mockResolvedValueOnce({ ok: true })
await expect(forkStructuredSessionFromTurn(args)).rejects.toThrow('newer Orca server')
// Nothing was sent, so the retired attempt must not replay the abandoned child id.
await forkStructuredSessionFromTurn(args)
expect(call.mock.calls[1]?.[2].envelope.sessionId).not.toBe(
call.mock.calls[0]?.[2].envelope.sessionId
)
})
it('evicts the oldest unconfirmed attempt instead of wedging every later fork', async () => {
call.mockRejectedValue(new Error('disconnected'))
const first = input()
await expect(forkStructuredSessionFromTurn(first)).rejects.toThrow('could not be confirmed')
for (let index = 0; index < 200; index += 1) {
await expect(forkStructuredSessionFromTurn(input())).rejects.toThrow('could not be confirmed')
}
call.mockReset()
call.mockResolvedValue({ ok: true })
// The 202nd fork in this app session must still reach the host.
await forkStructuredSessionFromTurn(input())
expect(call).toHaveBeenCalledTimes(1)
// The evicted entry no longer replays, so the retry mints a fresh child id.
await forkStructuredSessionFromTurn(first)
expect(call.mock.calls[1]?.[2].envelope.sessionId).not.toBe(
call.mock.calls[0]?.[2].envelope.sessionId
)
})
})