mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(native-chat): replay pending option mutations
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user