From 7f4a1d13f7cedfa42608152328f054a091b2afef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:54:16 -0700 Subject: [PATCH] fix(native-chat): replay pending option mutations --- ...use-mobile-structured-session-mutations.ts | 3 +- ...e-mobile-structured-session-writes.test.ts | 62 ++++++++++++++++++ .../use-structured-agent-session.test.tsx | 65 +++++++++++++++++++ .../use-structured-agent-session.ts | 3 +- src/shared/agent-session-refusal-retry.ts | 28 ++++++++ 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/shared/agent-session-refusal-retry.ts diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index a7499ddeedd..e40b4509ac2 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -12,6 +12,7 @@ import type { AgentSessionOptionResult, AgentSessionPromptResult } from '../../../src/shared/agent-session-wire' +import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint @@ -103,7 +104,7 @@ export function useMobileStructuredSessionMutations(args: { } const result = response.result as AgentSessionMutationResult if (!result.ok) { - if (result.refusal.code !== 'agent_session_operation_unknown') { + if (agentSessionRefusalOperationState(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 3d7d3c638c7..991696ade24 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.test.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.test.ts @@ -424,6 +424,68 @@ describe('useMobileStructuredSessionWrites', () => { expect(operationIds[1]).not.toBe(operationIds[0]) }) + it('reuses a pending ledger operation after an admission refusal without ledger growth', async () => { + sendRequest + .mockResolvedValueOnce({ + id: 'set-option-stale', + ok: true, + _meta: { runtimeId: 'runtime-1' }, + result: { + ok: false, + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'runtime fence advanced', + currentFence: 4 + } + } + }) + .mockResolvedValueOnce({ + id: 'set-option-replayed', + ok: true, + _meta: { runtimeId: 'runtime-1' }, + result: { + ok: true, + replayed: false, + fence: 4, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + key: 'model', + value: 'gpt-5.6-sol', + options: { model: 'gpt-5.6-sol' } + } + } + }) + + await act(async () => { + expect(await api!.setOption('model', 'gpt-5.6-sol')).toBeNull() + }) + fence = 4 + await act(async () => { + renderer!.update(createElement(Probe)) + await Promise.resolve() + }) + await act(async () => { + expect(await api!.setOption('model', 'gpt-5.6-sol')).toMatchObject({ + options: { model: 'gpt-5.6-sol' } + }) + }) + + const operationIds = sendRequest.mock.calls.map( + ([, params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + expect(operationIds[0]).toBe(operationIds[1]) + expect(operationIds[0]).toMatch(/-00000000000040008000000000000001$/) + expect( + sendRequest.mock.calls.map( + ([, params]) => + (params as { envelope: { expectedRuntimeFence: number } }).envelope.expectedRuntimeFence + ) + ).toEqual([3, 4]) + const crypto = await import('expo-crypto') + expect(crypto.randomUUID).toHaveBeenCalledTimes(1) + }) + 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 3edb8e23186..ee668e7ff8e 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 @@ -191,6 +191,71 @@ describe('useStructuredAgentSession options', () => { ).toEqual(['operation-1', 'operation-2']) }) + it('reuses a pending ledger operation after an admission refusal without ledger growth', 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: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + } + ) + }) + const { result, rerender } = 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.setStructuredOption('model', 'gpt-fast')).toBe(false) + }) + fence = 4 + rerender() + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + const mutations = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.setOption' + ) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + ).toEqual(['operation-1', 'operation-1']) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { expectedRuntimeFence: number } }).envelope.expectedRuntimeFence + ) + ).toEqual([3, 4]) + expect(mocks.operationId).toHaveBeenCalledTimes(1) + }) + 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 b54d1237ed7..aeda607d4e2 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 @@ -12,6 +12,7 @@ import type { } from '../../../../shared/agent-session-wire' import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import { applyStructuredAgentSessionOptions, @@ -105,7 +106,7 @@ export function useStructuredAgentSession(args: { return null } if (!result.ok) { - if (result.refusal.code !== 'agent_session_operation_unknown') { + if (agentSessionRefusalOperationState(result.refusal.code) === 'settled-rejected') { operationIds.current.delete(key) } if (stateRef.current.fence === targetFence) { diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts new file mode 100644 index 00000000000..9b9c08d4239 --- /dev/null +++ b/src/shared/agent-session-refusal-retry.ts @@ -0,0 +1,28 @@ +import type { AgentSessionWireRefusalCode } from './agent-session-wire' + +export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' + +export function agentSessionRefusalOperationState( + code: AgentSessionWireRefusalCode +): AgentSessionRefusalOperationState { + switch (code) { + case 'agent_session_operation_invalid': + case 'agent_session_item_revision_stale': + case 'agent_session_already_resolved': + return 'settled-rejected' + case 'agent_session_operation_unknown': + return 'unknown' + case 'structured_agent_session_unsupported': + case 'agent_session_checkpoint_stale': + case 'agent_session_conflict': + case 'agent_session_ownership_unknown': + case 'agent_session_operation_conflict': + case 'agent_session_operation_expired': + case 'agent_session_operation_capacity': + case 'agent_session_identity_required': + case 'agent_session_journal_unreadable': + case 'execution_owner_reconciling': + // These refusals do not prove the operation reached durable settlement. + return 'pending-admission' + } +}