mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
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`.
127 lines
4.8 KiB
TypeScript
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
|
|
)
|
|
})
|
|
})
|