fix(native-chat): replay pending option mutations

This commit is contained in:
Brennan Benson
2026-08-12 16:54:16 -07:00
parent 973dd58aba
commit 7f4a1d13f7
5 changed files with 159 additions and 2 deletions
@@ -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<TValue>
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)) {
@@ -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<RpcSuccess>()
sendRequest.mockImplementationOnce(() => first.promise).mockResolvedValueOnce(accepted('b'))
@@ -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<never>((_resolve, rejectPromise) => {
@@ -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) {
+28
View File
@@ -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'
}
}