fix(native-chat): rotate settled handoff operations

This commit is contained in:
Brennan Benson
2026-08-12 17:04:49 -07:00
parent 7f4a1d13f7
commit d82d807180
6 changed files with 160 additions and 5 deletions
@@ -104,7 +104,10 @@ export function useMobileStructuredSessionMutations(args: {
}
const result = response.result as AgentSessionMutationResult<TValue>
if (!result.ok) {
if (agentSessionRefusalOperationState(result.refusal.code) === 'settled-rejected') {
if (
agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) ===
'settled-rejected'
) {
operationIdsRef.current.delete(mutationKey)
}
if (matchesActiveContext(activeContextRef.current, client, sessionId, fence)) {
@@ -424,7 +424,7 @@ describe('useMobileStructuredSessionWrites', () => {
expect(operationIds[1]).not.toBe(operationIds[0])
})
it('reuses a pending ledger operation after an admission refusal without ledger growth', async () => {
it('reuses an option operation after a pending admission refusal', async () => {
sendRequest
.mockResolvedValueOnce({
id: 'set-option-stale',
@@ -486,6 +486,56 @@ describe('useMobileStructuredSessionWrites', () => {
expect(crypto.randomUUID).toHaveBeenCalledTimes(1)
})
it('mints a fresh handoff operation after a settled refusal', async () => {
sendRequest
.mockResolvedValueOnce({
id: 'handoff-stale',
ok: true,
_meta: { runtimeId: 'runtime-1' },
result: {
ok: false,
refusal: {
code: 'agent_session_checkpoint_stale',
message: 'runtime fence advanced',
currentFence: 4
}
}
})
.mockResolvedValueOnce({
id: 'handoff-started',
ok: true,
_meta: { runtimeId: 'runtime-1' },
result: {
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: {
status: {
owner: 'native',
direction: 'to-tui',
phase: 'switching',
stage: 'preparing',
operationId: 'second-operation'
}
}
}
})
await act(async () => {
expect(await api!.requestHandoff('to-tui', 'now')).toBe(false)
expect(await api!.requestHandoff('to-tui', 'now')).toBe(true)
})
const operationIds = sendRequest.mock.calls.map(
([, params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
)
expect(operationIds[0]).toMatch(/-00000000000040008000000000000001$/)
expect(operationIds[1]).toMatch(/-00000000000040008000000000000002$/)
expect(operationIds[1]).not.toBe(operationIds[0])
})
it('isolates mutation ids and late errors across session changes', async () => {
const first = deferred<RpcSuccess>()
sendRequest.mockImplementationOnce(() => first.promise).mockResolvedValueOnce(accepted('b'))
@@ -71,7 +71,10 @@ describe('useStructuredAgentSession options', () => {
beforeEach(() => {
vi.clearAllMocks()
fence = 3
mocks.operationId.mockReturnValueOnce('operation-1').mockReturnValueOnce('operation-2')
mocks.operationId
.mockReset()
.mockReturnValueOnce('operation-1')
.mockReturnValueOnce('operation-2')
mocks.call.mockImplementation((_target, method) =>
method === 'agentSession.options' ? Promise.resolve(OPTIONS) : Promise.resolve(null)
)
@@ -191,7 +194,7 @@ describe('useStructuredAgentSession options', () => {
).toEqual(['operation-1', 'operation-2'])
})
it('reuses a pending ledger operation after an admission refusal without ledger growth', async () => {
it('reuses an option operation after a pending admission refusal', async () => {
let attempts = 0
mocks.call.mockImplementation((_target, method) => {
if (method === 'agentSession.options') {
@@ -256,6 +259,65 @@ describe('useStructuredAgentSession options', () => {
expect(mocks.operationId).toHaveBeenCalledTimes(1)
})
it('mints a fresh handoff operation after a settled refusal', async () => {
let attempts = 0
mocks.call.mockImplementation((_target, method) => {
if (method === 'agentSession.options') {
return Promise.resolve(OPTIONS)
}
attempts += 1
return Promise.resolve(
attempts === 1
? {
ok: false,
refusal: {
code: 'agent_session_checkpoint_stale',
message: 'runtime fence advanced',
currentFence: 4
}
}
: {
ok: true,
replayed: false,
value: {
status: {
owner: 'native',
direction: 'to-tui',
phase: 'switching',
stage: 'preparing',
operationId: 'operation-2'
}
}
}
)
})
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
target: LOCAL_TARGET,
agent: 'codex'
})
)
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
await act(async () => {
expect(await result.current.requestHandoff('to-tui', 'now')).toBeNull()
expect(await result.current.requestHandoff('to-tui', 'now')).toMatchObject({
status: { phase: 'switching' }
})
})
const mutations = mocks.call.mock.calls.filter(
([, method]) => method === 'agentSession.requestHandoff'
)
expect(
mutations.map(
([, , params]) =>
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
)
).toEqual(['operation-1', 'operation-2'])
})
it('ignores an option failure from a superseded fence', async () => {
let reject!: (error: Error) => void
const pending = new Promise<never>((_resolve, rejectPromise) => {
@@ -106,7 +106,10 @@ export function useStructuredAgentSession(args: {
return null
}
if (!result.ok) {
if (agentSessionRefusalOperationState(result.refusal.code) === 'settled-rejected') {
if (
agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) ===
'settled-rejected'
) {
operationIds.current.delete(key)
}
if (stateRef.current.fence === targetFence) {
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { AgentSessionWireRefusalCode } from './agent-session-wire'
import { agentSessionRefusalOperationState } from './agent-session-refusal-retry'
const HANDOFF_SETTLED_CODES = [
'structured_agent_session_unsupported',
'agent_session_checkpoint_stale',
'agent_session_conflict',
'agent_session_operation_conflict'
] as const satisfies readonly AgentSessionWireRefusalCode[]
describe('agentSessionRefusalOperationState', () => {
it.each(HANDOFF_SETTLED_CODES)('classifies handoff %s as settled', (code) => {
expect(agentSessionRefusalOperationState('agentSession.requestHandoff', code)).toBe(
'settled-rejected'
)
})
it.each(['agentSession.setOption', 'agentSession.send'])(
'keeps %s admission refusals pending',
(method) => {
for (const code of HANDOFF_SETTLED_CODES) {
expect(agentSessionRefusalOperationState(method, code)).toBe('pending-admission')
}
}
)
})
+10
View File
@@ -3,8 +3,18 @@ import type { AgentSessionWireRefusalCode } from './agent-session-wire'
export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown'
export function agentSessionRefusalOperationState(
method: string,
code: AgentSessionWireRefusalCode
): AgentSessionRefusalOperationState {
if (method === 'agentSession.requestHandoff') {
switch (code) {
case 'structured_agent_session_unsupported':
case 'agent_session_checkpoint_stale':
case 'agent_session_conflict':
case 'agent_session_operation_conflict':
return 'settled-rejected'
}
}
switch (code) {
case 'agent_session_operation_invalid':
case 'agent_session_item_revision_stale':