Keep chat pickers usable while options recover (#37168)

* Keep chat pickers usable while options recover

* Show only the current process's reported permission and effort after a restart

When a native chat's agent restarted, the first frame at the new process could still show the old process's reported permission (for example "Full access" on an Own chat) as pickable, then flip to unknown a moment later. A reported value now counts only while the options answer that carried it belongs to the current process, on desktop and phone.

Also updates the restart tests: a pick made while the new process's options read is pending or failing goes to the new process on both desktop and phone.
This commit is contained in:
Brennan Benson
2026-11-01 19:19:11 -08:00
committed by GitHub
parent 9fb4929055
commit f037753ee3
15 changed files with 426 additions and 51 deletions
@@ -711,7 +711,7 @@ it('phone captures accepted echoed inputs, preserves false, and freezes empty vs
await harness.unmount()
})
it('refuses a typed literal at an unresolved fence and accepts it when that fence is ready with no menu', async () => {
it('accepts a typed literal at an attached fence while options refresh with no menu', async () => {
const pending = deferred<AgentSessionOptionsResult>()
let reads = 0
const client = optionsClient(() =>
@@ -726,15 +726,15 @@ it('refuses a typed literal at an unresolved fence and accepts it when that fenc
await act(async () => {
result = await view.current().setStructuredOption('model', 'literal')
})
expect(result).toBe(false)
expect(calls).toHaveLength(0)
expect(result).toBe(true)
expect(calls).toHaveLength(1)
await act(async () => pending.resolve({ models: [], current: {} }))
await settle()
await act(async () => {
result = await view.current().setStructuredOption('model', 'literal')
})
expect(result).toBe(true)
expect(calls).toHaveLength(1)
expect(calls).toHaveLength(2)
await view.unmount()
})
@@ -4,7 +4,6 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session/lifecycle/agent-session-provider-handle'
import { structuredAgentSessionSeedCatalog } from '../../../src/shared/agent-session/options/structured-agent-session-seed-catalog'
import type { AgentSessionOptionResult } from '../../../src/shared/agent-session/agent-session-wire'
import { lockedStructuredAgentSessionOptionSnapshot } from '../../../src/shared/agent-session/options/structured-agent-session-option-snapshot'
import {
structuredAgentSessionOptionView,
structuredAgentSessionSendSelection,
@@ -57,7 +56,7 @@ export function useMobileStructuredAgentOptions(args: {
)
// An options answer made before the account's listing landed listed none of it.
const {
projectPermission,
projectRuntimeReports,
optionState,
optionStateRef,
activeOptionRecordRef,
@@ -65,7 +64,6 @@ export function useMobileStructuredAgentOptions(args: {
updateOptionState,
conversationSupport,
imageSupport,
awaitingOptions,
receiveCatalog,
isCurrentContext,
controller,
@@ -107,19 +105,9 @@ export function useMobileStructuredAgentOptions(args: {
imageSupport?.sessionId === sessionId &&
!imageSupport.statesPermissionMode &&
optionState.permissionMode === null
const interactionReadyRef = useRef(!awaitingOptions)
const lockSnapshotRef = useRef(
(snapshot: ReturnType<typeof structuredAgentSessionShownSnapshot>) => snapshot
)
const lockSnapshot = (snapshot: ReturnType<typeof structuredAgentSessionShownSnapshot>) =>
awaitingOptions ? lockedStructuredAgentSessionOptionSnapshot(snapshot) : snapshot
useLayoutEffect(() => {
interactionReadyRef.current = !awaitingOptions
lockSnapshotRef.current = lockSnapshot
})
const readOptionState = useCallback(
() =>
projectPermission({
projectRuntimeReports({
...structuredAgentSessionOptionView(
optionStateRef.current,
undefined,
@@ -127,15 +115,15 @@ export function useMobileStructuredAgentOptions(args: {
),
permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null
}),
[controller, projectPermission, hostSupport?.permissionOwn]
[controller, projectRuntimeReports, hostSupport?.permissionOwn]
)
const shown = lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState()))
const shown = structuredAgentSessionShownSnapshot(readOptionState())
const optionSnapshot = legacyHostPermissions
? [...shown, legacyHostPermissionDescriptor()]
: shown
const readWriteContext = () => {
const currentState = projectPermission({
const currentState = projectRuntimeReports({
...optionStateRef.current,
permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null
})
@@ -144,8 +132,8 @@ export function useMobileStructuredAgentOptions(args: {
controller,
record: optionStateRef.current.record,
view,
snapshot: lockSnapshotRef.current(structuredAgentSessionShownSnapshot(view)),
ready: !awaitingOptions && !!client && !!sessionId && interactionReadyRef.current,
snapshot: structuredAgentSessionShownSnapshot(view),
ready: !!client && !!sessionId,
providerStarting,
isCurrentContext,
flush: (isCurrent: () => boolean) => {
@@ -199,7 +187,6 @@ export function useMobileStructuredAgentOptions(args: {
useMobileStructuredOptionSurface({
optionSnapshot,
demandModelCatalog,
lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot),
setStructuredOption,
readOptionState
})
@@ -0,0 +1,149 @@
// A replacement process's options read can fail; the chat's known choices still reach that process.
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import {
CAPABLE,
SESSION_ID,
ok,
snapshotEvent
} from './use-mobile-structured-agent-session-queued.test-fixture'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined)
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const client: RpcClient = {
sendRequest,
subscribe: vi.fn<RpcClient['subscribe']>((_method, _params, onData) => {
if (_method === 'agentSession.subscribe') {
listener = onData
}
return vi.fn()
}),
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
const OPTIONS = {
models: ['gpt-fast', 'gpt-slow'].map((id, index) => ({
id,
label: id,
isDefault: index === 0,
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
})),
current: { model: 'gpt-fast', effort: 'low', permissionMode: 'ask' }
}
// This host predates the model listing, so the options answer is the chat's only model list.
async function hostAnswer(method: string) {
return method === 'agentSession.modelCatalog'
? {
id: 'request-1',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
: ok(method === 'agentSession.options' ? OPTIONS : {})
}
function Harness(): null {
hook = useMobileStructuredAgentSession({
client,
sessionId: SESSION_ID,
sourceIdentity: 'host-a\0workspace-a',
enabled: true,
connected: true,
agent: 'codex',
hostSupport: { ...CAPABLE, permissionModes: true, permissionOwn: true },
onSendError: vi.fn()
})
return null
}
const optionReads = () =>
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.options').length
const descriptor = (id: string) => hook!.optionSnapshot.find((entry) => entry.id === id)
beforeEach(() => {
vi.clearAllMocks()
sendRequest.mockImplementation(hostAnswer)
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hook = null
listener = null
})
it('sends a pick on a restarted process to its new fence while its options reads fail', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotEvent({ fence: 3 })))
await vi.waitFor(() => expect(descriptor('permissionMode')).toMatchObject({ settable: true }))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
const beforeRestart = optionReads()
sendRequest.mockImplementation(async (method) => {
if (method === 'agentSession.options') {
throw new Error('request timed out')
}
return method === 'agentSession.setOption'
? ok({
ok: true,
replayed: false,
fence: 4,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: { key: 'model', value: 'gpt-slow', options: { model: 'gpt-slow' } }
})
: hostAnswer(method)
})
act(() => listener?.(snapshotEvent({ fence: 4 })))
// The replacement process's read and both retries fail; the chat's known choices stay usable.
await vi.waitFor(() => expect(optionReads()).toBe(beforeRestart + 3), { timeout: 3000 })
for (const id of ['model', 'effort', 'permissionMode']) {
expect(descriptor(id), id).toMatchObject({ settable: true })
}
await act(async () => {
expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true)
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.setOption',
expect.objectContaining({
envelope: expect.objectContaining({ sessionId: SESSION_ID, expectedRuntimeFence: 4 }),
key: 'model',
value: 'gpt-slow'
}),
expect.any(Object)
)
// Only an accepted pick is remembered as the agent's choice.
expect(sendRequest).toHaveBeenCalledWith('settings.mutateNativeChatSessionOptions', {
type: 'apply-picks',
agent: 'codex',
picks: [{ modelId: 'gpt-slow', optionId: 'model', value: 'gpt-slow' }]
})
})
@@ -690,6 +690,10 @@ describe('useMobileStructuredAgentSession', () => {
settable: true
})
})
// The picker is usable before fence 3's own read answers; only a read issued after the pick settles it.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
let reconcile!: (response: Awaited<ReturnType<typeof defaultSendRequest>>) => void
sendRequest.mockImplementation(async (method, params) => {
if (method === 'agentSession.setOption') {
@@ -9,7 +9,10 @@ import { createStructuredAgentSessionOptionsReadOwner } from '../../../src/share
import type { RpcClient } from '../transport/rpc-client'
import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session/options/agent-session-option-catalog-types'
import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership'
import { createStructuredAgentSessionOptionState } from '../../../src/shared/agent-session/options/structured-agent-session-options'
import {
createStructuredAgentSessionOptionState,
structuredAgentSessionReportsAtFence
} from '../../../src/shared/agent-session/options/structured-agent-session-options'
export function useMobileStructuredOptionState(args: {
client: RpcClient | null
@@ -53,14 +56,23 @@ export function useMobileStructuredOptionState(args: {
sessionId ? feed?.getSnapshot().get(sessionId) : undefined
)
const providerStarting = summary?.hostExecutionPhase === 'starting'
const projectPermission = <T extends { reportedPermissionMode?: string | null }>(state: T): T =>
projectRuntimePermission(state, summary, fence)
const coherentCatalog = usesInstalledAgentModelCatalog(agent ?? '', hostCoherentCatalog)
const [reader] = useState(() =>
createStructuredAgentSessionOptionsReadOwner(
createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog)
)
)
// An older fence's report is stale from the render its fence changes in, before re-attach clears it.
const projectRuntimeReports = <
T extends { reportedPermissionMode?: string | null; reportedEffort?: string }
>(
state: T
): T =>
projectRuntimePermission(
structuredAgentSessionReportsAtFence(state, reader.getSnapshot().support?.fence, fence),
summary,
fence
)
const { optionState, support } = useSyncExternalStore(reader.subscribe, reader.getSnapshot)
const {
optionStateRef,
@@ -91,7 +103,6 @@ export function useMobileStructuredOptionState(args: {
? {
conversationSupport: {
sessionId: support.sessionId,
fence: support.fence,
commands: support.result.conversationCommands ?? []
},
imageSupport: {
@@ -103,8 +114,6 @@ export function useMobileStructuredOptionState(args: {
: { conversationSupport: null, imageSupport: null },
[support]
)
const awaitingOptions =
conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence
useEffect(
() =>
reader.attach({
@@ -168,10 +177,9 @@ export function useMobileStructuredOptionState(args: {
}, [readStamp, reread])
return {
projectPermission,
projectRuntimeReports,
conversationSupport,
imageSupport,
awaitingOptions,
controller,
reread,
receiveCatalog,
@@ -9,7 +9,6 @@ import type {
export function useMobileStructuredOptionSurface(args: {
demandModelCatalog: () => void
lockSnapshot: (snapshot: SessionOptionDescriptor[]) => SessionOptionDescriptor[]
optionSnapshot: SessionOptionDescriptor[]
setStructuredOption: (id: string, value: SessionOptionValue) => Promise<boolean>
/** The latest option state, which a settled pick's snapshot is read from. */
@@ -35,9 +34,9 @@ export function useMobileStructuredOptionSurface(args: {
const setOption = useCallback(
async (id: string, value: SessionOptionValue) => {
await setStructuredOption(id, value)
return { snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState())) }
return { snapshot: structuredAgentSessionShownSnapshot(readOptionState()) }
},
[args.lockSnapshot, readOptionState, setStructuredOption]
[readOptionState, setStructuredOption]
)
const optionSurface = useMemo<SessionOptionsSurface>(
@@ -11,7 +11,10 @@ import type { AgentType } from '../../../../shared/agent-status-types'
import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session/options/agent-session-option-catalog-types'
import { usesInstalledAgentModelCatalog } from '../../../../shared/agent-model-catalog-ownership'
import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/agent-session/structured-agent-session-surface-capabilities'
import type { StructuredAgentSessionOptionState } from '../../../../shared/agent-session/options/structured-agent-session-options'
import {
structuredAgentSessionReportsAtFence,
type StructuredAgentSessionOptionState
} from '../../../../shared/agent-session/options/structured-agent-session-options'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { structuredAgentSessionOptionView } from '../../../../shared/agent-session/options/structured-agent-session-option-view'
import {
@@ -209,13 +212,19 @@ export function useStructuredAgentSessionOptionState(args: {
explicit?: Readonly<Record<string, string>>
) => ({
...projectRuntimePermission(
structuredAgentSessionOptionView(state, seed, controller.overlay(), explicit),
structuredAgentSessionOptionView(
// An older fence's report is stale from the render its fence changes in, before re-attach clears it.
structuredAgentSessionReportsAtFence(state, reader.supportFor(owner)?.fence, fence),
seed,
controller.overlay(),
explicit
),
summary,
fence
),
permissionOwnSupported
}),
[controller, summary, fence, permissionOwnSupported]
[reader, owner, controller, summary, fence, permissionOwnSupported]
)
const takesSendOptions = useStructuredAgentSessionHostCapability(
target,
@@ -460,7 +460,7 @@ describe('a new chat picker’s first frame', () => {
})
})
it('refuses typed literals while the current fence awaits options, then accepts with an empty catalog', async () => {
it('accepts typed literals at an attached fence while options refresh with an empty catalog', async () => {
const pending = deferred<AgentSessionOptionsResult>()
let reads = 0
mocks.call.mockImplementation((_target: unknown, method: string) =>
@@ -497,10 +497,21 @@ it('refuses typed literals while the current fence awaits options, then accepts
view.rerender({ fence: 2 })
await waitFor(() => expect(reads).toBe(2))
await act(async () =>
expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(false)
expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true)
)
expect(mutate).not.toHaveBeenCalled()
expect(mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.setOption')).toEqual([])
expect(mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.setOption')).toEqual([
[
LOCAL_TARGET,
'agentSession.setOption',
{
envelope: expect.objectContaining({ sessionId: 'locked-chat', expectedRuntimeFence: 2 }),
key: 'model',
value: 'literal'
},
{}
]
])
await act(async () => pending.resolve({ models: [], current: {} }))
await act(async () =>
expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true)
@@ -12,6 +12,7 @@ import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { useLayoutEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ call: vi.fn(), write: vi.fn() }))
@@ -80,10 +81,14 @@ function permission(snapshot: readonly SessionOptionDescriptor[]): string | null
return entry?.kind.type === 'select' ? entry.kind.currentValue : null
}
function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') {
function renderReopened(
fence: number,
agent: 'codex' | 'claude' = 'codex',
onCommit?: (snapshot: readonly SessionOptionDescriptor[]) => void
) {
return renderHook(
(props: { fence: number; stamp: string }) =>
useStructuredAgentSessionOptions({
(props: { fence: number; stamp: string }) => {
const options = useStructuredAgentSessionOptions({
agent,
sessionId: 'session-1',
target: LOCAL_TARGET,
@@ -95,7 +100,10 @@ function renderReopened(fence: number, agent: 'codex' | 'claude' = 'codex') {
unloadedTurnRevisions: undefined,
hostOptionStamp: props.stamp,
mutate
}),
})
useLayoutEffect(() => onCommit?.(options.optionSnapshot))
return options
},
{ initialProps: { fence, stamp: 'model-a|ask' } }
)
}
@@ -137,11 +145,18 @@ describe('the permission mode another device picks', () => {
const answer = await read(...args)
return { ...answer, current: { ...answer.current, reportedPermissionMode: 'full-access' } }
})
const { result, rerender, unmount } = renderReopened(1)
const committed: string[] = []
const { result, rerender, unmount } = renderReopened(1, 'codex', (snapshot) => {
const entry = snapshot.find((descriptor) => descriptor.id === 'permissionMode')
committed.push(`${entry?.description}/${entry?.settable}`)
})
const row = () => result.current.optionSnapshot.find((entry) => entry.id === 'permissionMode')
await waitFor(() => expect(row()).toMatchObject({ description: 'Full access', settable: true }))
mocks.call.mockImplementation(() => new Promise(() => {}))
committed.length = 0
rerender({ fence: 2, stamp: 'model-a|own' })
// Every frame of the new process, including the one before its read lease attaches.
expect([...new Set(committed)]).toEqual(['Permissions/false'])
expect(row()).toMatchObject({ description: 'Permissions', settable: false })
unmount()
})
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import {
mocks,
answer,
mutateWith,
ATTACHED,
renderOptions,
descriptor,
LIVE_OPTIONS,
SAVED_LIST,
tick
} from './use-structured-agent-session-options.test-fixture'
import { act, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller'
import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import {
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY,
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY
} from '../../../../shared/agent-session/agent-session-permission-modes-capability'
it('keeps known pickers writable after a restarted process options read fails', async () => {
forgetStructuredSessionSelection('session-1')
resetHostModelCatalogSnapshotsForTests()
mocks.call.mockReset()
mocks.pick.mockReset()
mocks.coherentCatalog = true
mocks.sendSelection = false
setLocalRuntimeCapabilitiesForTests([
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY,
AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY
])
const failed = Promise.withResolvers<unknown>()
let reads = 0
answer({
modelCatalog: () => Promise.resolve(SAVED_LIST),
options: () =>
++reads === 1
? Promise.resolve({
...LIVE_OPTIONS,
models: LIVE_OPTIONS.models.map((model) => ({
...model,
efforts: [{ value: 'medium', label: 'Medium' }]
})),
current: { ...LIVE_OPTIONS.current, effort: 'medium', permissionMode: 'ask' }
})
: failed.promise
})
const { result, rerender, unmount } = renderOptions(ATTACHED, mutateWith(async () => null).mutate)
try {
await waitFor(() =>
expect(result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true)
)
const beforeRestart = reads
rerender({ ...ATTACHED, fence: 2 })
await act(async () => {
failed.reject(new Error('host timeout'))
await tick()
})
await waitFor(() => expect(reads).toBe(beforeRestart + 3))
for (const id of ['model', 'effort', 'permissionMode']) {
expect(descriptor(result.current.optionSnapshot, id)?.settable, id).toBe(true)
}
mocks.pick.mockResolvedValue({
key: 'model',
value: 'gpt-5.6-luna',
options: { model: 'gpt-5.6-luna' }
})
let applied = false
await act(async () => {
applied = await result.current.setStructuredOption('model', 'gpt-5.6-luna')
})
expect(applied).toBe(true)
expect(mocks.pick).toHaveBeenCalled()
} finally {
unmount()
forgetStructuredSessionSelection('session-1')
setLocalRuntimeCapabilitiesForTests(null)
}
})
@@ -62,9 +62,7 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt
unloadedTurnRevisions: args.unloadedTurnRevisions
})
const awaitingOptions = support?.sessionId === sessionId && support.fence !== fence
const acceptsPicks =
(!transportEnabled || fence !== null) && launch?.seedPending !== true && !awaitingOptions
const acceptsPicks = (!transportEnabled || fence !== null) && launch?.seedPending !== true
const hostCatalog = useHostModelCatalogUpgrade({
agent,
@@ -375,11 +375,12 @@ describe('useStructuredAgentSession options', () => {
})
fence = 4
rerender()
expect(result.current.optionSnapshot.every((entry) => !entry.settable)).toBe(true)
await act(async () => refreshedOptions.resolve(OPTIONS))
// The replacement process's options read is still pending; the known model stays pickable.
expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')?.settable).toBe(true)
await act(async () => {
expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true)
})
await act(async () => refreshedOptions.resolve(OPTIONS))
const mutations = mocks.call.mock.calls.filter(
([, method]) => method === 'agentSession.setOption'
@@ -240,6 +240,16 @@ function structuredAgentSessionControlsModelId(
: null
}
/** Runtime reports describe the process whose options answer carried them; a newer fence has none until it answers. */
export function structuredAgentSessionReportsAtFence<
T extends Pick<StructuredAgentSessionOptionState, 'reportedPermissionMode' | 'reportedEffort'>
>(state: T, answeredFence: number | null | undefined, fence: number | null): T {
return answeredFence === fence ||
(state.reportedPermissionMode === undefined && state.reportedEffort === undefined)
? state
: { ...state, reportedPermissionMode: undefined, reportedEffort: undefined }
}
export function commitStructuredAgentSessionOption(
state: StructuredAgentSessionOptionState,
id: string,
@@ -234,3 +234,84 @@ it('clears account-switch evidence only on a current owner read failure and reta
expect(reader.supportFor(owner)?.result.canSwitchAccount).toBe(true)
close()
})
it('retries a failed options read for a restarted process without an unrelated event', async () => {
vi.useFakeTimers()
const { reader, context, reads } = fixture()
let close = reader.attach(context)
try {
await tick()
reads[0].resolve(answer)
await tick()
close()
close = reader.attach({ ...context, reset: [2], fence: 2 })
await tick()
reads[1].reject(new Error('host timeout'))
await tick()
await vi.advanceTimersByTimeAsync(1_000)
expect(reads).toHaveLength(3)
reads[2].resolve(answer)
await tick()
expect(reader.getSnapshot().support?.fence).toBe(2)
} finally {
close()
vi.useRealTimers()
}
})
it('bounds failed reads, wakes on invalidation, and cancels a detached lease retry', async () => {
vi.useFakeTimers()
const { reader, context, reads, controller } = fixture()
let close = reader.attach(context)
try {
await tick()
for (let attempt = 0; attempt < 3; attempt++) {
reads[attempt].reject(new Error('offline'))
await tick()
await vi.advanceTimersByTimeAsync(1_000)
}
expect(reads).toHaveLength(3)
await vi.advanceTimersByTimeAsync(60_000)
expect(reads).toHaveLength(3)
expect(reader.getSnapshot().support).toBeNull()
reader.invalidate()
await tick()
expect(reads).toHaveLength(4)
const pick = controller.issue('model', 'picked', null)
controller.settle(pick, { kind: 'accepted', options: { model: 'picked' } })
reads[3].reject(new Error('offline'))
await tick()
await vi.advanceTimersByTimeAsync(250)
expect(reads).toHaveLength(5)
const newer = controller.issue('effort', 'high', 'picked')
controller.settle(newer, { kind: 'accepted', options: { effort: 'high' } })
reads[4].resolve(answer)
await tick()
expect(reader.getSnapshot().support?.result).toBe(answer)
expect(controller.captureRead()).toEqual([newer.identity])
expect(controller.overlay()).toEqual({ effort: 'high' })
reader.invalidate()
await tick()
reads[5].reject(new Error('offline'))
await tick()
close()
close = reader.attach({ ...context, reset: [2], fence: 2 })
await tick()
expect(reads).toHaveLength(7)
reads[6].resolve(answer)
await tick()
await vi.advanceTimersByTimeAsync(60_000)
expect(reads).toHaveLength(7)
expect(reader.getSnapshot().support?.fence).toBe(2)
reader.invalidate()
await tick()
reads[7].reject(new Error('offline'))
await tick()
close()
await vi.advanceTimersByTimeAsync(60_000)
expect(reads).toHaveLength(8)
} finally {
close()
vi.useRealTimers()
}
})
@@ -50,6 +50,12 @@ export function createStructuredAgentSessionOptionsReadOwner(
let leases = 0
let version = 0
let dirty = true
let retries = 0
let retryTimer: ReturnType<typeof setTimeout> | undefined
const cancelRetry = () => {
clearTimeout(retryTimer)
retryTimer = undefined
}
const listeners = new Set<() => void>()
const notify = () => listeners.forEach((listener) => listener())
const run = createCoalescingKeyedRunner<void>()
@@ -100,11 +106,24 @@ export function createStructuredAgentSessionOptionsReadOwner(
}
notify()
}
if (isCurrentRead() && !dirty && retries < 2) {
retries++
retryTimer = setTimeout(() => {
retryTimer = undefined
if (!isCurrentRead()) {
return
}
dirty = true
schedule()
}, 250)
}
return
}
if (!isCurrentRead()) {
return
}
retries = 0
cancelRetry()
snapshot = {
optionState: attached.optionCatalog
? applyStructuredAgentSessionOptions(
@@ -127,6 +146,8 @@ export function createStructuredAgentSessionOptionsReadOwner(
}).catch(() => undefined)
}
const invalidate = () => {
cancelRetry()
retries = 0
version++
dirty = true
schedule()
@@ -181,6 +202,7 @@ export function createStructuredAgentSessionOptionsReadOwner(
invalidate()
return () => {
if (lease === attached) {
cancelRetry()
lease = undefined
version++
dirty = true