mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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>(
|
||||
|
||||
+12
-3
@@ -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,
|
||||
|
||||
+14
-3
@@ -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)
|
||||
|
||||
+20
-5
@@ -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()
|
||||
})
|
||||
|
||||
+81
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user