diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index e40b4509ac2..c3ec2d2c4c5 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -104,7 +104,10 @@ export function useMobileStructuredSessionMutations(args: { } const result = response.result as AgentSessionMutationResult 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)) { diff --git a/mobile/src/session/use-mobile-structured-session-writes.test.ts b/mobile/src/session/use-mobile-structured-session-writes.test.ts index 991696ade24..db28cd0d722 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.test.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.test.ts @@ -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() sendRequest.mockImplementationOnce(() => first.promise).mockResolvedValueOnce(accepted('b')) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index ee668e7ff8e..04fa10f32ff 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -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((_resolve, rejectPromise) => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index aeda607d4e2..e3c306468b6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -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) { diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts new file mode 100644 index 00000000000..7babff24ef4 --- /dev/null +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -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') + } + } + ) +}) diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts index 9b9c08d4239..6d0f6090d28 100644 --- a/src/shared/agent-session-refusal-retry.ts +++ b/src/shared/agent-session-refusal-retry.ts @@ -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':