mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(native-chat): rotate settled handoff operations
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user