diff --git a/mobile/src/session/use-mobile-structured-agent-options.declared-effort.test.ts b/mobile/src/session/use-mobile-structured-agent-options.declared-effort.test.ts index 1f094b3b9720..e3a23d0bbcb8 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.declared-effort.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.declared-effort.test.ts @@ -15,8 +15,9 @@ it('renders trustworthy context through the phone consumer while capturing only for (const input of ['low', 'max', 'opencode', 'absent'] as const) { const { agent, model, display, live, resting, catalog } = await recordedEffortCase(input) for (const answer of [live, resting]) { + let response = Promise.resolve(answer) const { client } = optionsClient( - async () => answer, + () => response, async () => catalog ) const harness = await mountOptions({ @@ -35,6 +36,12 @@ it('renders trustworthy context through the phone consumer while capturing only } expect(effort?.kind.currentValue).toBe(display ?? undefined) expect(harness.current().sendSelection()).toEqual({ selection: { model } }) + response = new Promise(() => {}) + await harness.rerender({ fence: 2 }) + expect(descriptorFor(harness.current().optionSnapshot, 'effort')?.kind.currentValue).toBe( + display ?? undefined + ) + expect(harness.current().sendSelection()).toEqual({ selection: { model } }) await harness.unmount() } } diff --git a/mobile/src/session/use-mobile-structured-agent-options.lifetime.test.ts b/mobile/src/session/use-mobile-structured-agent-options.lifetime.test.ts index 92a9d90b45fd..8b6f9d85db36 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.lifetime.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.lifetime.test.ts @@ -35,7 +35,7 @@ it('keeps actual phone operations and correlated acceptance through a stable-cli const stale = deferred() let oldReads = 0 const physical = optionsClient(() => - ++oldReads === 1 ? Promise.resolve(OPTIONS) : stale.promise + ++oldReads === 1 ? Promise.resolve({ ...OPTIONS, acceptsImages: true }) : stale.promise ) const currentReads: ReturnType>[] = [] const next = optionsClient(() => { @@ -77,8 +77,11 @@ it('keeps actual phone operations and correlated acceptance through a stable-cli await client.migrateTo(next.client, 'relay') }) await settle() - await act(async () => stale.resolve({ ...OPTIONS, conversationCommands: ['compact'] })) + await act(async () => + stale.resolve({ ...OPTIONS, acceptsImages: false, conversationCommands: ['compact'] }) + ) expect(view.current().conversationCommands).toEqual([]) + expect(view.current().acceptsImages).toBe(true) expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-fast') expect(view.current().sendSelection()).toMatchObject({ selection: { model: 'gpt-fast' } }) await act(async () => @@ -217,6 +220,33 @@ it('reads the new physical path after an idle connected-to-connected migration w client.close() }) +it('coalesces a burst of turns into one running and one trailing options read', async () => { + const reads: ReturnType>[] = [] + const rpc = optionsClient(() => { + const read = deferred() + reads.push(read) + return read.promise + }) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const view = await mountOptions({ ...BASE, client: rpc.client, mutate }) + expect(reads).toHaveLength(1) + await act(async () => reads[0].resolve(OPTIONS)) + await settle() + for (let turn = 1; turn <= 10; turn++) { + await view.rerender({ turnId: `turn-${turn}` }) + } + expect(reads).toHaveLength(2) + await act(async () => reads[1].resolve({ ...OPTIONS, conversationCommands: ['clear'] })) + await settle() + expect(view.current().conversationCommands).toEqual([]) + expect(reads).toHaveLength(3) + await act(async () => reads[2].resolve({ ...OPTIONS, conversationCommands: ['compact'] })) + await settle() + expect(reads).toHaveLength(3) + expect(view.current().conversationCommands).toEqual(['compact']) + await view.unmount() +}) + it.each(['current', 'failed-then-wake', 'true-end'] as const)( 'settles a late refusal after a physical reset without losing progress (%s)', async (schedule) => { diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index af475729f332..1eb994938a10 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -1,3 +1,8 @@ +import * as selectionOperations from '../../../src/shared/structured-agent-session-selection-operations' +import { + rememberMobileCreatedStructuredSession, + mobileCreatedStructuredSession +} from './mobile-created-structured-sessions' import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' @@ -97,6 +102,7 @@ async function mountOptions(props: ProbeProps) { await settle() return { pending: () => rendered.current?.pendingOptionId, + images: () => rendered.current?.acceptsImages, offered: () => { const entry = rendered.current?.optionSnapshot.find( (descriptor) => descriptor.id === 'permissionMode' @@ -131,19 +137,52 @@ async function mountOptions(props: ProbeProps) { // STA-10671: a 1.4.224 host ignores per-chat modes and runs its own Agent Permissions. describe('the permission pill against a host predating per-chat modes', () => { - it("says the chat runs the host's Agent Permissions, and offers nothing to pick", async () => { + it('publishes legacyHostPermissions only after a current options answer', async () => { + const factory = vi.spyOn(selectionOperations, 'createStructuredSessionSelectionController') + const old = Promise.withResolvers() + const current = Promise.withResolvers() + const rpc = optionsClient(undefined) + const send = rpc.client.sendRequest + let reads = 0 + rpc.client.sendRequest = async (method, params, config) => + method === 'agentSession.options' + ? { + id: 'held', + ok: true, + result: await (++reads === 1 ? old.promise : current.promise), + _meta: { runtimeId: 'runtime-1' } + } + : send(method, params, config) + rememberMobileCreatedStructuredSession('session-1', '/workspace') const options = await mountOptions({ - client: optionsClient(undefined).client, + client: rpc.client, fence: 1, - optionStamp: 'gpt-live|', + optionStamp: '', hostAdvertisesPermissionModes: false }) + const constructed = factory.mock.results.at(-1) + if (constructed?.type !== 'return') { + throw new Error('Phone selection owner was not created') + } + const controller = constructed.value + await act(async () => { + const pick = controller.issue('model', 'gpt-live', null) + controller.settle(pick, { kind: 'unknown' }) + }) + await act(async () => old.resolve({ ...optionsFor(), acceptsImages: false })) + expect(options.permission()).toBeNull() + expect(options.images()).toBe(true) + expect(mobileCreatedStructuredSession('session-1')).toEqual({ worktree: '/workspace' }) + await act(async () => current.resolve({ ...optionsFor(), acceptsImages: false })) expect(options.permission()).toBe('host') expect(options.offered()).toEqual(['host']) + expect(options.images()).toBe(false) + expect(mobileCreatedStructuredSession('session-1')).toBeUndefined() vi.mocked(mutate).mockClear() expect(await options.pick('own', true)).toBe(false) expect(mutate).not.toHaveBeenCalled() await options.unmount() + factory.mockRestore() }) // 1.4.225–1.4.226 apply modes without advertising the capability: their stated mode wins. diff --git a/mobile/src/session/use-mobile-structured-option-state.ts b/mobile/src/session/use-mobile-structured-option-state.ts index 348750c57925..c3a26865ac0c 100644 --- a/mobile/src/session/use-mobile-structured-option-state.ts +++ b/mobile/src/session/use-mobile-structured-option-state.ts @@ -1,26 +1,15 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' import { projectRuntimePermission } from '../../../src/shared/structured-agent-session-permission-option' -import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session/agent-session-conversation-command' import { useMobileStructuredSelectionController } from './use-mobile-structured-selection-controller' -import type { - AgentSessionOptionsResult, - AgentSessionModelCatalogResult -} from '../../../src/shared/agent-session-wire' +import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' import { callAgentSession } from './mobile-structured-agent-session-rpc' import { forgetMobileCreatedStructuredSession } from './mobile-created-structured-sessions' -import { createCoalescingKeyedRunner } from '../../../src/shared/coalescing-keyed-runner' +import { createStructuredAgentSessionOptionsReadOwner } from '../../../src/shared/structured-agent-session-options-read-owner' import type { RpcClient } from '../transport/rpc-client' import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' import { usesInstalledAgentModelCatalog } from '../../../src/shared/agent-model-catalog-ownership' -import { cloneStructuredAgentSessionOptionRecord } from '../../../src/shared/structured-agent-session-option-state' -import { - createStructuredAgentSessionOptionState, - applyStructuredAgentSessionOptions, - applyStructuredAgentSessionModelCatalog, - settleStructuredAgentSessionBuiltinCatalog, - type StructuredAgentSessionOptionState -} from '../../../src/shared/structured-agent-session-options' +import { createStructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' export function useMobileStructuredOptionState(args: { client: RpcClient | null @@ -67,31 +56,25 @@ export function useMobileStructuredOptionState(args: { const projectPermission = (state: T): T => projectRuntimePermission(state, summary, fence) const coherentCatalog = usesInstalledAgentModelCatalog(agent ?? '', hostCoherentCatalog) - const [optionState, setOptionState] = useState(() => - createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) + const [reader] = useState(() => + createStructuredAgentSessionOptionsReadOwner( + createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) + ) ) - const optionStateRef = useRef(optionState) - const activeOptionRecordRef = useRef(optionState.record) - const readEpoch = useRef(0) - const readRef = useRef<(() => void) | null>(null) - const reread = useCallback(() => { - readEpoch.current += 1 - readRef.current?.() - }, []) + const { optionState, support } = useSyncExternalStore(reader.subscribe, reader.getSnapshot) + const { + optionStateRef, + activeOptionRecordRef, + updateOptionState, + receiveCatalog, + invalidate: reread + } = reader const { controller, clientGeneration } = useMobileStructuredSelectionController({ agent, client, sessionId, onRevalidate: reread }) - const updateOptionState = useCallback( - (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { - const next = update(optionStateRef.current) - optionStateRef.current = next - setOptionState(next) - }, - [] - ) const contextKey = JSON.stringify([agent, sessionId, newLaunch, worktree, fence, connected]) const contextRef = useRef({ key: contextKey, client }) useEffect(() => { @@ -101,151 +84,88 @@ export function useMobileStructuredOptionState(args: { () => contextRef.current.key === contextKey && contextRef.current.client === client, [contextKey, client] ) - const optionIdentityRef = useRef({ agent, sessionId, client, controller }) - useEffect(() => { - const previousIdentity = optionIdentityRef.current - const sameSession = - previousIdentity.agent === agent && - previousIdentity.sessionId === sessionId && - previousIdentity.client === client && - previousIdentity.controller === controller - optionIdentityRef.current = { agent, sessionId, client, controller } - const previous = optionStateRef.current - const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) - // Retain account answers across fence changes instead of reviving placeholders. - const kept = - sameSession && - (previous.optionsAnswerPresent || - (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) + // Projected once per accepted answer so consumers keep stable references between answers. + const { conversationSupport, imageSupport } = useMemo( + () => + support && support.sessionId !== null ? { - ...previous, - record: cloneStructuredAgentSessionOptionRecord(previous.record), - pendingId: null, - reportedPermissionMode: undefined, - reportedEffort: undefined + conversationSupport: { + sessionId: support.sessionId, + fence: support.fence, + commands: support.result.conversationCommands ?? [] + }, + imageSupport: { + sessionId: support.sessionId, + acceptsImages: support.result.acceptsImages, + statesPermissionMode: support.result.current?.permissionMode !== undefined + } } - : seeded - // Keep the chat’s mode visible through provider restarts. - const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept - readEpoch.current += 1 - optionStateRef.current = next - activeOptionRecordRef.current = next.record - setOptionState(next) - }, [ - agent, - enabled, - fence, - optionCatalog, - sessionId, - contextKey, - client, - clientGeneration, - controller - ]) - - const [conversationSupport, setConversationSupport] = useState<{ - sessionId: string - commands: readonly AgentSessionConversationCommand[] - fence: number | null - } | null>(null) - const [imageSupport, setImageSupport] = useState<{ - sessionId: string - acceptsImages: boolean | undefined - statesPermissionMode: boolean - } | null>(null) - + : { conversationSupport: null, imageSupport: null }, + [support] + ) const awaitingOptions = conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence - - const receiveCatalog = ( - catalog: AgentSessionModelCatalogResult | null, - _event: 'demand' | 'progress' | 'settled' - ) => { - updateOptionState((current) => - catalog && optionCatalog - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch, - coherentCatalog + useEffect( + () => + reader.attach({ + owner: [agent, sessionId, client, controller], + reset: [contextKey, clientGeneration, enabled, optionCatalog], + sessionId, + fence, + seed: () => createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog), + controller, + eligible: + !!client && + !!sessionId && + enabled && + !!optionCatalog && + (!providerStarting || !newLaunch || !!sendSelection), + optionCatalog, + newLaunch, + coherentCatalog, + authority: () => client?.getGeneration?.(), + isCurrent: isCurrentContext, + request: () => { + if (!client || !sessionId) { + throw new Error('Options reader is not attached') + } + return callAgentSession(client, 'agentSession.options', { + sessionId }) - : settleStructuredAgentSessionBuiltinCatalog(current, false) - ) - if (!coherentCatalog && catalog && catalog.origin !== 'unknown' && !catalog.listingInProgress) { - readRef.current?.() - } - } - useEffect(() => { - // Read each turn and after provider start; before start only a resumed chat has model evidence. - if ( - !client || - !sessionId || - !enabled || - !optionCatalog || - (providerStarting && newLaunch && !sendSelection) - ) { - return - } - let stale = false - const run = createCoalescingKeyedRunner() - const request = () => { - void run('options', 'latest', async () => { - if (stale || !isCurrentContext()) { - return - } - const record = activeOptionRecordRef.current - const epoch = readEpoch.current - const generation = client.getGeneration?.() - const completed = controller.captureRead() - const result = await callAgentSession( - client, - 'agentSession.options', - { sessionId } - ) - if (stale || !isCurrentContext() || activeOptionRecordRef.current !== record) { - return - } - // Latch for this view; a later view may run the model selected here. - forgetMobileCreatedStructuredSession(sessionId) - setImageSupport({ - sessionId, - acceptsImages: result.acceptsImages, - statesPermissionMode: result.current?.permissionMode !== undefined - }) - if (readEpoch.current !== epoch || client.getGeneration?.() !== generation) { - return + }, + accepted: () => { + if (sessionId) { + forgetMobileCreatedStructuredSession(sessionId) + } } - setConversationSupport({ sessionId, fence, commands: result.conversationCommands ?? [] }) - updateOptionState((current) => - applyStructuredAgentSessionOptions(current, optionCatalog, result, { - newLaunch, - coherentCatalog - }) - ) - controller.retire(completed) - }).catch(() => undefined) - } - readRef.current = request - request() - return () => { - stale = true - readRef.current = null + }), + [ + reader, + agent, + sessionId, + client, + controller, + contextKey, + clientGeneration, + enabled, + optionCatalog, + fence, + newLaunch, + coherentCatalog, + providerStarting, + sendSelection, + isCurrentContext + ] + ) + // A turn or host status change rereads within the same lease, as on desktop; it is not a new context. + const readStamp = JSON.stringify([optionStamp, turnId]) + const seenReadStamp = useRef(readStamp) + useEffect(() => { + if (seenReadStamp.current !== readStamp) { + seenReadStamp.current = readStamp + reread() } - }, [ - client, - clientGeneration, - newLaunch, - enabled, - optionCatalog, - optionStamp, - sendSelection, - controller, - isCurrentContext, - coherentCatalog, - providerStarting, - sessionId, - fence, - turnId, - updateOptionState - ]) + }, [readStamp, reread]) return { projectPermission, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx index 466a1fa3c0a8..5f6ab2dc7da1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx @@ -188,10 +188,15 @@ describe('useStructuredAgentSession context usage', () => { : Promise.resolve(null) ) const { result, rerender } = render() + // Reads start one microtask after they are scheduled, so let each start before its revisions. + await waitFor(() => expect(replies).toHaveLength(1)) await act(async () => replies[0](150_000)) await waitFor(() => expect(result.current.contextUsage).toMatchObject({ usedTokens: 150_000 })) - for (let revision = 1; revision <= 5; revision += 1) { + unloadedTurnRevisions = 1 + rerender() + await waitFor(() => expect(optionReads()).toBe(2)) + for (let revision = 2; revision <= 5; revision += 1) { unloadedTurnRevisions = revision rerender() } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 456656b7c421..81f33db023ee 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -1,4 +1,3 @@ -import type { StructuredSessionConversationSupport } from './structured-agent-session-options-contract' import { structuredSessionSelectionAuthority, structuredSessionSelectionController @@ -6,21 +5,13 @@ import { import { useStructuredSessionSelectionReads } from './use-structured-session-selection-reads' import { projectRuntimePermission } from '../../../../shared/structured-agent-session-permission-option' import { useStructuredAgentSessionStatusSummary } from './StructuredAgentSessionStatusBridge' -import { useCallback, useEffect, useRef, useState } from 'react' -import type { - AgentSessionOptionsResult, - AgentSessionModelCatalogResult -} from '../../../../shared/agent-session-wire' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import { usesInstalledAgentModelCatalog } from '../../../../shared/agent-model-catalog-ownership' import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities' -import { - applyStructuredAgentSessionModelCatalog, - applyStructuredAgentSessionOptions, - settleStructuredAgentSessionBuiltinCatalog, - type StructuredAgentSessionOptionState -} from '../../../../shared/structured-agent-session-options' +import type { StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { @@ -33,11 +24,7 @@ import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { cloneStructuredAgentSessionOptionRecord } from '../../../../shared/structured-agent-session-option-state' -import { - createCoalescedPollRunner, - type CoalescedPollRunner -} from '../right-sidebar/coalesced-poll-runner' +import { createStructuredAgentSessionOptionsReadOwner } from '../../../../shared/structured-agent-session-options-read-owner' export { firstFrameOptionState } from './structured-agent-session-option-first-frame' import { firstFrameOptionState } from './structured-agent-session-option-first-frame' @@ -77,39 +64,50 @@ export function useStructuredAgentSessionOptionState(args: { worktree } = args const { summary, observation } = useStructuredAgentSessionStatusSummary(sessionId, target) - const [conversationSupport, setConversationSupport] = - useState(null) - // A revision the loaded window dropped can move the host's whole-journal context facts. - const contextRefresh = conversationSupport?.contextUsage ? (args.unloadedTurnRevisions ?? 0) : 0 - // Paint cached host evidence immediately; without it keep the placeholder until the host names the model. - const [optionState, setOptionState] = useState(() => - firstFrameOptionState(agent, target, { - newLaunch, - seedsModel, - ...(worktree ? { worktree } : {}) - }) + const [reader] = useState(() => + createStructuredAgentSessionOptionsReadOwner( + firstFrameOptionState(agent, target, { + newLaunch, + seedsModel, + ...(worktree ? { worktree } : {}) + }) + ) ) - // Read only when a fence or session resets the state, so a new target alone resets nothing. - const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel }) - useEffect(() => { - firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel } - }, [newLaunch, seedsModel, target, worktree]) - const optionStateRef = useRef(optionState) - const activeOptionRecordRef = useRef(optionState.record) + const { optionState, support } = useSyncExternalStore(reader.subscribe, reader.getSnapshot) + // Projected once per accepted answer so consumers keep stable references between answers. + const conversationSupport = useMemo( + () => + support && support.sessionId !== null + ? { + sessionId: support.sessionId, + commands: support.result.conversationCommands ?? [], + threadGoal: support.result.threadGoal, + reportedThreadGoal: support.result.reportedThreadGoal, + contextUsage: support.result.contextUsage, + acceptsImages: support.result.acceptsImages, + rewind: support.result.rewind ?? { + supported: false as const, + reason: 'unsupported' as const + }, + fence: support.fence + } + : null, + [support] + ) + const contextRefresh = conversationSupport?.contextUsage ? (args.unloadedTurnRevisions ?? 0) : 0 + const { + optionStateRef, + activeOptionRecordRef, + updateOptionState, + receiveCatalog, + invalidate: rereadOptions + } = reader const authority = structuredSessionSelectionAuthority(target) const controller = structuredSessionSelectionController(target, sessionId, agent) const takesSelection = useStructuredAgentSessionHostCapability( target, AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY ) - const updateOptionState = useCallback( - (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { - const next = update(optionStateRef.current) - optionStateRef.current = next - setOptionState(next) - }, - [] - ) const hostKey = structuredAgentSessionHostKey(target) const contextKey = JSON.stringify([identity, hostKey, newLaunch, worktree, fence, observation]) const contextRef = useRef(contextKey) @@ -121,135 +119,70 @@ export function useStructuredAgentSessionOptionState(args: { agent, useStructuredAgentSessionHostCapability(target, AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY) ) - // A chat this view started names the default it runs until its first send pins it. - const applyOptionsAnswer = useCallback( - (current: StructuredAgentSessionOptionState, result: AgentSessionOptionsResult) => - optionCatalog - ? applyStructuredAgentSessionOptions(current, optionCatalog, result, { + useEffect( + () => + reader.attach({ + owner: [agent, sessionId, hostKey, identity, controller], + reset: [contextKey, authority], + sessionId, + fence, + seed: () => + firstFrameOptionState(agent, target, { newLaunch, - coherentCatalog - }) - : current, - [newLaunch, optionCatalog, coherentCatalog] + seedsModel, + ...(worktree ? { worktree } : {}) + }), + controller, + eligible: + providerVisible && + (!providerStarting || readsBeforeStart || takesSelection) && + optionCatalog !== null, + optionCatalog, + newLaunch, + coherentCatalog, + authority: () => structuredSessionSelectionAuthority(target), + isCurrent: () => isCurrentContext() && controller.isCurrent(), + request: () => + controller.request((options) => + callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId }, + options + ) + ) + }), + [ + reader, + agent, + sessionId, + hostKey, + identity, + controller, + contextKey, + authority, + fence, + target, + newLaunch, + seedsModel, + worktree, + providerVisible, + providerStarting, + readsBeforeStart, + takesSelection, + optionCatalog, + coherentCatalog, + isCurrentContext + ] ) - const host = target.kind === 'local' ? 'local' : target.environmentId - const optionIdentity = JSON.stringify([identity, host]) - const optionIdentityRef = useRef({ identity: optionIdentity, controller }) - useEffect(() => { - const previous = optionStateRef.current - const sameSession = - optionIdentityRef.current.identity === optionIdentity && - optionIdentityRef.current.controller === controller - optionIdentityRef.current = { identity: optionIdentity, controller } - const launch = firstFrameLaunch.current - const seeded = firstFrameOptionState(agent, launch.target, launch) - // Retain saved picks/account listings across same-session restarts until the starting provider can be read. - const kept = - sameSession && - (previous.optionsAnswerPresent || - (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) - ? { - ...previous, - record: cloneStructuredAgentSessionOptionRecord(previous.record), - pendingId: null, - reportedPermissionMode: undefined, - reportedEffort: undefined - } - : seeded - const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept - optionStateRef.current = next - activeOptionRecordRef.current = next.record - setOptionState(next) - }, [agent, fence, optionIdentity, contextKey, authority, controller]) - - const readEpoch = useRef(0) - const optionsReadRef = useRef(null) - // Read each turn and after provider start to confirm the model the host actually runs. - useEffect(() => { - if ( - !providerVisible || - (providerStarting && !readsBeforeStart && !takesSelection) || - !optionCatalog - ) { - return - } - let stale = false - const runner = createCoalescedPollRunner(async () => { - if (!isCurrentContext()) { - return - } - const epoch = readEpoch.current - const record = activeOptionRecordRef.current - const completed = controller.captureRead() - const result = await controller.request((options) => - callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId }, - options - ) - ) - if ( - !stale && - isCurrentContext() && - readEpoch.current === epoch && - structuredSessionSelectionAuthority(target) === authority && - activeOptionRecordRef.current === record - ) { - setConversationSupport({ - sessionId, - commands: result.conversationCommands ?? [], - threadGoal: result.threadGoal, - reportedThreadGoal: result.reportedThreadGoal, - contextUsage: result.contextUsage, - acceptsImages: result.acceptsImages, - rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, - fence - }) - updateOptionState((current) => - current.record === record ? applyOptionsAnswer(current, result) : current - ) - controller.retire(completed) - } - }) - optionsReadRef.current = runner - runner.run() - return () => { - stale = true - runner.dispose() - optionsReadRef.current = null - } - }, [ - applyOptionsAnswer, - isCurrentContext, - authority, - controller, - takesSelection, - fence, - optionCatalog, - providerStarting, - providerVisible, - readsBeforeStart, - sessionId, - target, - updateOptionState - ]) - // Options share the host queue with sends/interrupts; coalesce revision bursts into one active and one pending read. const seenContextRefresh = useRef(contextRefresh) useEffect(() => { if (contextRefresh !== seenContextRefresh.current) { seenContextRefresh.current = contextRefresh - readEpoch.current += 1 - optionsReadRef.current?.run() + rereadOptions() } - }, [contextRefresh]) - - // An options answer made before the account's listing landed listed none of it. - const rereadOptions = useCallback(() => { - readEpoch.current += 1 - optionsReadRef.current?.run() - }, []) + }, [contextRefresh, rereadOptions]) useStructuredSessionSelectionReads({ controller, target, @@ -258,22 +191,6 @@ export function useStructuredAgentSessionOptionState(args: { turnId: args.turnId, rereadOptions }) - const receiveCatalog = ( - catalog: AgentSessionModelCatalogResult | null, - _event: 'demand' | 'progress' | 'settled' - ) => { - updateOptionState((current) => - catalog && optionCatalog - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch, - coherentCatalog - }) - : settleStructuredAgentSessionBuiltinCatalog(current, false) - ) - if (!coherentCatalog && catalog && catalog.origin !== 'unknown' && !catalog.listingInProgress) { - rereadOptions() - } - } const permissionOwnSupport = useStructuredAgentSessionOwnPermissionState(target) const permissionOwnSupported = permissionOwnSupport === 'unknown' ? null : permissionOwnSupport === 'supported' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx index e47cbbd944e3..11f348068cd1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx @@ -246,6 +246,10 @@ describe('declared context at the desktop consumer', () => { expect(effort).toBeUndefined() } expect(chat.result.current.capture()).toEqual({ selection: { model } }) + mocks.call.mockImplementation(() => new Promise(() => {})) + chat.rerender({ running: { fence: 2 } }) + expect(chat.frames.at(-1)).toEqual({ model, effort: display ?? null }) + expect(chat.result.current.capture()).toEqual({ selection: { model } }) chat.unmount() } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 10048774d862..75582d3f1614 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -60,7 +60,7 @@ describe('useStructuredAgentSessionOptions', () => { })), current: { model, ...(effort ? { effort } : {}) } }) - const initial = menu('family') + const initial = { ...menu('family'), acceptsImages: true } const older = Promise.withResolvers() const latest = Promise.withResolvers() let reads = 0 @@ -91,9 +91,10 @@ describe('useStructuredAgentSessionOptions', () => { rerender({ ...REOPENED, agent: 'antigravity', hidden: true }) rerender({ ...REOPENED, agent: 'antigravity' }) await waitFor(() => expect(reads).toBe(3)) - await act(async () => latest.resolve(menu('newer', 'high'))) + await act(async () => latest.resolve({ ...menu('newer', 'high'), acceptsImages: true })) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('high')) - await act(async () => older.resolve(menu('family', 'low'))) + await act(async () => older.resolve({ ...menu('family', 'low'), acceptsImages: false })) + expect(result.current.acceptsImages).toBe(true) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('newer') expect(descriptor(result.current.optionSnapshot, 'effort')).toMatchObject({ settable: true, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index e7623ab7c2ed..975ba9dc4732 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -1,3 +1,4 @@ +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract' import { toast } from 'sonner' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' @@ -23,8 +24,9 @@ import { export type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract' export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOptionsArgs) { - const { agent, fence, launch, providerVisible, sessionId, target, transportEnabled, turnId } = - args + const { agent, fence, launch, providerVisible, sessionId, transportEnabled, turnId } = args + const environmentId = args.target.kind === 'environment' ? args.target.environmentId : null + const target = useMemo(() => runtimeTargetForOwnerEnvironment(environmentId), [environmentId]) const capturePresentation = args.capturePresentation const providerStarting = args.providerStarting ?? false const newLaunch = launch?.kind === 'new' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-paired-runtime.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-paired-runtime.test.tsx index ce37c875f8fd..ae50bc1a42a7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-paired-runtime.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-paired-runtime.test.tsx @@ -217,7 +217,10 @@ describe('a structured chat on a paired server', () => { expect.any(Function) ) expect(mocks.subscribe.mock.calls.every(([target]) => target === PAIRED_TARGET)).toBe(true) - expect(mocks.call.mock.calls.every(([target]) => target === PAIRED_TARGET)).toBe(true) + // The options view memoizes an equal target by environment, so routing compares by value. + expect(mocks.call.mock.calls.map(([target]) => target)).toEqual( + mocks.call.mock.calls.map(() => PAIRED_TARGET) + ) }) it("reads the rail's conversation outline from the server", async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-session-selection-reads.performance.test.tsx b/src/renderer/src/components/native-chat/use-structured-session-selection-reads.performance.test.tsx index 1bbdd46ee83b..9b0112e09cb3 100644 --- a/src/renderer/src/components/native-chat/use-structured-session-selection-reads.performance.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-session-selection-reads.performance.test.tsx @@ -1,5 +1,9 @@ // @vitest-environment happy-dom +import { useSyncExternalStore } from 'react' +import { createStructuredAgentSessionOptionsReadOwner } from '../../../../shared/structured-agent-session-options-read-owner' +import { createStructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import { act, cleanup, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { createStructuredSessionSelectionController } from '../../../../shared/structured-agent-session-selection-operations' @@ -8,7 +12,10 @@ import { useStructuredAgentSessionOptionStamp } from './structured-agent-session import { useAppStore } from '@/store' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import type * as StructuredClient from '@/runtime/structured-agent-session-client' -import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' +import type { + AgentSessionOptionsResult, + AgentSessionStatusEvent +} from '../../../../shared/agent-session-wire' import { getStructuredAgentSessionStatusFeed, resetStructuredAgentSessionStatusFeedsForTests @@ -26,6 +33,32 @@ vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ( supportsStructuredAgentSessionContinuationContext: vi.fn(async () => false) })) +const options: AgentSessionOptionsResult = { models: [], current: {} } +function readOwner( + controller: ReturnType, + request: () => Promise +) { + const optionCatalog = getAgentSessionOptionCatalog('codex') + const seed = () => createStructuredAgentSessionOptionState('codex', optionCatalog) + const reader = createStructuredAgentSessionOptionsReadOwner(seed()) + const close = reader.attach({ + owner: [controller], + reset: [], + sessionId: 'selected', + fence: 1, + seed, + controller, + eligible: true, + optionCatalog, + newLaunch: false, + coherentCatalog: true, + authority: () => 'local', + isCurrent: () => true, + request + }) + return { reader, close } +} + const initialState = useAppStore.getState() afterEach(() => { cleanup() @@ -52,7 +85,13 @@ it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes withou options: Record }>() ) - const reread = vi.fn() + const reads: ReturnType>[] = [] + const { reader, close } = readOwner(controller, () => { + const reply = Promise.withResolvers() + reads.push(reply) + return reply.promise + }) + const reread = vi.fn(reader.invalidate) const capture = vi.spyOn(controller, 'captureRead') let renders = 0 let visits = 0 @@ -65,6 +104,7 @@ it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes withou return all(entries) }) function Probe() { + useSyncExternalStore(reader.subscribe, reader.getSnapshot) useStructuredSessionSelectionReads({ controller, target, @@ -80,6 +120,9 @@ it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes withou return null } const view = render() + await act(async () => { + await Promise.resolve() + }) const baseline = renders for (let index = 0; index < count; index++) { act(() => { @@ -108,7 +151,7 @@ it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes withou expect(renders - baseline).toBe(count) expect(memberships).toBe(0) expect(visits).toBeLessThanOrEqual(40 * count) - expect(capture).not.toHaveBeenCalled() + expect(capture).toHaveBeenCalledTimes(1) expect(reread).not.toHaveBeenCalled() measurements.push({ count, sends, renders: renders - baseline, visits, memberships }) await act(async () => @@ -121,6 +164,17 @@ it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes withou act(() => controller.retire(captured)) expect(controller.captureRead()).toEqual([2]) expect(controller.overlay()).toEqual({}) + for (let index = 0; index < count; index++) { + reader.invalidate() + } + expect(reads).toHaveLength(1) + await act(async () => reads[0].resolve(options)) + expect(reads).toHaveLength(2) + expect(reader.getSnapshot().support).toBeNull() + await act(async () => reads[1].resolve(options)) + expect(reads).toHaveLength(2) + expect(controller.captureRead()).toEqual([]) + close() const draining = controller.drain() act(() => controller.abandon()) await draining @@ -155,8 +209,13 @@ it('keeps unrelated store, same-host status and other-host consumers quiet at N createStructuredSessionSelectionController(vi.fn()) ) const renders = Array.from({ length: count }, () => 0) - const rereads = controllers.map(() => vi.fn()) + const transport = vi.fn(async (_index: number) => options) + const owners = controllers.map((controller, index) => + readOwner(controller, () => transport(index)) + ) + const rereads = owners.map(({ reader }) => vi.fn(reader.invalidate)) function Probe({ index }: { index: number }) { + useSyncExternalStore(owners[index].reader.subscribe, owners[index].reader.getSnapshot) const stamp = useStructuredAgentSessionOptionStamp(`session-${index}`, target) useStructuredSessionSelectionReads({ controller: controllers[index], @@ -174,13 +233,14 @@ it('keeps unrelated store, same-host status and other-host consumers quiet at N await act(async () => { stop = other.activate() }) + transport.mockClear() const baseline = [...renders] - const publish = (host: string, sessionId: string, model: string) => { + const publish = async (host: string, sessionId: string, model: string) => { const emit = emits.get(host) if (!emit) { throw new Error('Missing test status transport') } - act(() => + await act(async () => emit({ type: 'snapshot', sessions: [ @@ -200,15 +260,18 @@ it('keeps unrelated store, same-host status and other-host consumers quiet at N snapshot.mockClear() act(() => useAppStore.setState({ lastTerminalInputAtByPaneKey: { other: 1 } })) expect(snapshot).not.toHaveBeenCalled() - publish('other', 'session-0', 'other-model') + await publish('other', 'session-0', 'other-model') expect(snapshot).not.toHaveBeenCalled() - publish('local', 'unmounted', 'unrelated-model') + await publish('local', 'unmounted', 'unrelated-model') const upstreamReads = snapshot.mock.calls.length expect(upstreamReads).toBeGreaterThanOrEqual(count) expect(upstreamReads).toBeLessThanOrEqual(count * 3) expect(renders).toEqual(baseline) - publish('local', 'session-0', 'sonnet') - expect(renders).toEqual(baseline.map((value, index) => value + (index === 0 ? 1 : 0))) + expect(transport).not.toHaveBeenCalled() + await publish('local', 'session-0', 'sonnet') + expect(renders.slice(1)).toEqual(baseline.slice(1)) + expect(renders[0]).toBeGreaterThan(baseline[0]) + expect(transport).toHaveBeenCalledExactlyOnceWith(0) expect(rereads[0]).toHaveBeenCalledTimes(1) const beforePick = [...renders] act(() => controllers[1].issue('model', 'opus', null)) @@ -218,6 +281,7 @@ it('keeps unrelated store, same-host status and other-host consumers quiet at N JSON.stringify({ count, upstreamReads, unrelatedRenders: 0 }) ) view.unmount() + owners.forEach(({ close }) => close()) stop() snapshot.mockRestore() resetStructuredAgentSessionStatusFeedsForTests() diff --git a/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx b/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx index 6148fec18f4d..e27b9e4efc7c 100644 --- a/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx +++ b/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx @@ -184,7 +184,8 @@ it.each([false, true])( unloadedTurnRevisions: undefined }) ) - expect(reads).toHaveLength(1) + // The read starts one microtask after its attach, from the shared queue. + await waitFor(() => expect(reads).toHaveLength(1)) const capture = () => { const source = hook.result.current.sendSource const view = source.optionView(source.optionStateRef.current, seed) diff --git a/src/shared/structured-agent-session-options-read-owner.test.ts b/src/shared/structured-agent-session-options-read-owner.test.ts new file mode 100644 index 000000000000..73e0df6b8a66 --- /dev/null +++ b/src/shared/structured-agent-session-options-read-owner.test.ts @@ -0,0 +1,195 @@ +import { expect, it, vi } from 'vitest' +import type { AgentSessionOptionsResult } from './agent-session-wire' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { createStructuredAgentSessionOptionState } from './structured-agent-session-options' +import { createStructuredSessionSelectionController } from './structured-agent-session-selection-operations' +import { createStructuredAgentSessionOptionsReadOwner } from './structured-agent-session-options-read-owner' + +const answer: AgentSessionOptionsResult = { + models: [{ id: 'live', label: 'Live', isDefault: true, efforts: [] }], + current: { model: 'live', permissionMode: 'ask', reportedPermissionMode: 'full-access' }, + acceptsImages: true +} +const tick = async () => { + for (let n = 0; n < 12; n++) { + await Promise.resolve() + } +} +function fixture() { + const optionCatalog = getAgentSessionOptionCatalog('codex') + const seed = () => createStructuredAgentSessionOptionState('codex', optionCatalog) + const reader = createStructuredAgentSessionOptionsReadOwner(seed()) + const controller = createStructuredSessionSelectionController(vi.fn()) + const reads: ReturnType>[] = [] + let generation = 1 + const context = { + owner: [controller], + reset: [1], + sessionId: 'chat', + fence: 1, + seed, + controller, + eligible: true, + optionCatalog, + newLaunch: false, + coherentCatalog: true, + authority: () => generation, + isCurrent: () => true, + accepted: vi.fn(), + request: vi.fn(() => { + const reply = Promise.withResolvers() + reads.push(reply) + return reply.promise + }) + } + return { + reader, + controller, + reads, + context, + advance: () => { + generation++ + } + } +} + +it('coalesces stale reads and publishes base/support/latch together before retiring only captured operations', async () => { + const { reader, controller, reads, context, advance } = fixture() + const close = reader.attach(context) + const changed = vi.fn() + const unsubscribe = reader.subscribe(changed) + const initial = reader.getSnapshot() + await tick() + const pick = controller.issue('model', 'picked', null) + controller.settle(pick, { kind: 'unknown' }) + for (let n = 0; n < 100; n++) { + reader.invalidate() + } + expect(reads).toHaveLength(1) + expect(changed).not.toHaveBeenCalled() + advance() + reads[0].resolve({ ...answer, acceptsImages: false }) + await tick() + expect(reader.getSnapshot()).toBe(initial) + expect(context.accepted).not.toHaveBeenCalled() + expect(controller.overlay()).toEqual({ model: 'picked' }) + expect(reads).toHaveLength(2) + const newer = controller.issue('effort', 'high', 'picked') + controller.settle(newer, { kind: 'accepted', options: { effort: 'high' } }) + // A catalog arriving during the read must survive an unknown-inventory answer. + reader.receiveCatalog({ origin: 'probe', fetchedAt: 1, models: answer.models }, 'settled') + const catalog = reader.getSnapshot().optionState.catalog + const stop = controller.subscribe(() => { + expect(reader.getSnapshot().support?.result.acceptsImages).toBe(true) + expect(reader.getSnapshot().optionState.optionsAnswerPresent).toBe(true) + }) + reads[1].resolve({ ...answer, modelsKnown: false }) + await tick() + expect(reader.getSnapshot().optionState.catalog).toBe(catalog) + expect(context.accepted).toHaveBeenCalledTimes(1) + expect(controller.captureRead()).toEqual([newer.identity]) + expect(controller.overlay()).toEqual({ effort: 'high' }) + stop() + close() + unsubscribe() +}) + +it('retains same-owner answers and permissions while resetting reports, and seeds a different owner but keeps its session-tagged support', async () => { + const { reader, context, reads } = fixture() + let close = reader.attach(context) + await tick() + reads[0].resolve(answer) + await tick() + const accepted = reader.getSnapshot() + close() + close = reader.attach({ ...context, reset: [2], fence: 2, eligible: false }) + const retained = reader.getSnapshot() + expect(retained.optionState.record).not.toBe(accepted.optionState.record) + expect(retained.optionState.catalog).toBe(accepted.optionState.catalog) + expect(retained.optionState.permissionMode).toBe('ask') + expect(retained.optionState.reportedPermissionMode).toBeUndefined() + expect(retained.optionState.reportedEffort).toBeUndefined() + expect(retained.support).toBe(accepted.support) + reader.invalidate() + reader.invalidate() + expect(reads).toHaveLength(1) + close() + close = reader.attach({ ...context, reset: [2], fence: 2 }) + await tick() + expect(reads).toHaveLength(2) + close() + const replacement = createStructuredSessionSelectionController(vi.fn()) + close = reader.attach({ + ...context, + controller: replacement, + owner: [replacement], + eligible: false + }) + expect(reader.getSnapshot().optionState.optionsAnswerPresent).toBeUndefined() + expect(reader.getSnapshot().optionState.permissionMode).toBeNull() + expect(reader.getSnapshot().support).toBe(accepted.support) + reads[1].resolve({ ...answer, acceptsImages: false }) + await tick() + expect(reader.getSnapshot().support).toBe(accepted.support) + close() +}) + +it('releases failed or closed reads and starts a fresh lease at once while the old reply stays inert', async () => { + const { reader, context, reads } = fixture() + const close = reader.attach(context) + await tick() + reader.invalidate() + close() + const detach = reader.attach(context) + close() // An old cleanup cannot close a newer lease. + await tick() + // The fresh lease does not wait behind the closed lease's request, whose queued read never runs. + expect(reads).toHaveLength(2) + reads[0].resolve({ ...answer, acceptsImages: false }) + await tick() + expect(reads).toHaveLength(2) + expect(reader.getSnapshot().support).toBeNull() + reader.invalidate() + reads[1].reject(new Error('offline')) + await tick() + expect(reads).toHaveLength(3) + expect(context.accepted).not.toHaveBeenCalled() + reads[2].resolve(answer) + await tick() + const accepted = reader.getSnapshot() + reader.invalidate() + await tick() + reads[3].reject(new Error('offline')) + await tick() + expect(reader.getSnapshot()).toBe(accepted) + reader.invalidate() + await tick() + expect(reads).toHaveLength(5) + reader.invalidate() + detach() + reads[4].resolve({ ...answer, acceptsImages: false }) + await tick() + expect(reads).toHaveLength(5) + expect(reader.getSnapshot()).toBe(accepted) + expect(context.accepted).toHaveBeenCalledTimes(1) +}) + +it('lets two live readers retire the same captured IDs without removing a newer pick', async () => { + const { reader, context, controller, reads } = fixture() + const older = controller.issue('model', 'picked', null) + controller.settle(older, { kind: 'unknown' }) + const peer = createStructuredAgentSessionOptionsReadOwner(context.seed()) + const closes = [reader.attach(context), peer.attach(context)] + await tick() + const newer = controller.issue('effort', 'high', 'picked') + controller.settle(newer, { kind: 'unknown' }) + for (const reply of reads) { + reply.resolve(answer) + await tick() + expect(controller.overlay()).toEqual({ effort: 'high' }) + expect(controller.captureRead()).toEqual([newer.identity]) + } + expect(reader.getSnapshot().support).not.toBeNull() + expect(peer.getSnapshot().support).not.toBeNull() + closes.forEach((close) => close()) +}) diff --git a/src/shared/structured-agent-session-options-read-owner.ts b/src/shared/structured-agent-session-options-read-owner.ts new file mode 100644 index 000000000000..2be6f72c2538 --- /dev/null +++ b/src/shared/structured-agent-session-options-read-owner.ts @@ -0,0 +1,195 @@ +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog' +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from './agent-session-wire' +import { createCoalescingKeyedRunner } from './coalescing-keyed-runner' +import { cloneStructuredAgentSessionOptionRecord } from './structured-agent-session-option-state' +import { + applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, + settleStructuredAgentSessionBuiltinCatalog, + type StructuredAgentSessionOptionState +} from './structured-agent-session-options' +import type { StructuredSessionSelectionController } from './structured-agent-session-selection-operations' + +type ReadContext = { + owner: readonly unknown[] + reset: readonly unknown[] + sessionId: string | null + fence: number | null + seed: () => StructuredAgentSessionOptionState + controller: StructuredSessionSelectionController + eligible: boolean + optionCatalog: AgentSessionOptionCatalog | null + newLaunch: boolean + coherentCatalog: boolean + authority: () => unknown + isCurrent: () => boolean + request: () => Promise + accepted?: () => void +} + +type ReadSnapshot = { + optionState: StructuredAgentSessionOptionState + support: { + result: AgentSessionOptionsResult + sessionId: string | null + fence: number | null + } | null +} +const same = (left: readonly unknown[], right: readonly unknown[]) => + left.length === right.length && left.every((value, index) => Object.is(value, right[index])) + +/** One live scope owns capture, accepted publication and causal retirement on either surface. */ +export function createStructuredAgentSessionOptionsReadOwner( + seed: StructuredAgentSessionOptionState +) { + let snapshot: ReadSnapshot = { optionState: seed, support: null } + let previous: ReadContext | undefined + let lease: (ReadContext & { key: string }) | undefined + let leases = 0 + let version = 0 + let dirty = true + const listeners = new Set<() => void>() + const notify = () => listeners.forEach((listener) => listener()) + const run = createCoalescingKeyedRunner() + const updateOptionState = ( + update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState + ) => { + const optionState = update(snapshot.optionState) + if (optionState !== snapshot.optionState) { + snapshot = { ...snapshot, optionState } + notify() + } + } + const schedule = () => { + const attached = lease + if (!attached?.eligible) { + return + } + // Coalesce within a lease; a new lease reads at once and the old lease's reply stays inert. + void run(attached.key, 'latest', async () => { + if (lease !== attached || !dirty || !attached.isCurrent()) { + return + } + dirty = false + const capturedVersion = version + const record = snapshot.optionState.record + const authority = attached.authority() + const completed = attached.controller.captureRead() + const result = await attached.request() + if ( + lease !== attached || + version !== capturedVersion || + snapshot.optionState.record !== record || + !attached.isCurrent() || + !Object.is(attached.authority(), authority) + ) { + return + } + snapshot = { + optionState: attached.optionCatalog + ? applyStructuredAgentSessionOptions( + snapshot.optionState, + attached.optionCatalog, + result, + attached + ) + : snapshot.optionState, + support: { result, sessionId: attached.sessionId, fence: attached.fence } + } + attached.accepted?.() + attached.controller.retire(completed) + notify() + }).catch(() => undefined) + } + const invalidate = () => { + version++ + dirty = true + schedule() + } + return { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + optionStateRef: { + get current() { + return snapshot.optionState + } + }, + activeOptionRecordRef: { + get current() { + return snapshot.optionState.record + } + }, + updateOptionState, + invalidate, + attach(context: ReadContext) { + const sameOwner = !previous || same(previous.owner, context.owner) + if (!previous || !sameOwner || !same(previous.reset, context.reset)) { + const current = snapshot.optionState + const kept = + sameOwner && + (current.optionsAnswerPresent || + (current.catalogSource !== 'seed' && current.catalogSource !== null)) + ? { + ...current, + record: cloneStructuredAgentSessionOptionRecord(current.record), + pendingId: null, + reportedPermissionMode: undefined, + reportedEffort: undefined + } + : context.seed() + // Support stays tagged with its session; consumers filter by session, so it bridges a re-pair. + snapshot = { + ...snapshot, + optionState: sameOwner ? { ...kept, permissionMode: current.permissionMode } : kept + } + notify() + } + previous = context + const attached = { ...context, key: String(++leases) } + lease = attached + invalidate() + return () => { + if (lease === attached) { + lease = undefined + version++ + dirty = true + } + } + }, + receiveCatalog( + catalog: AgentSessionModelCatalogResult | null, + _event: 'demand' | 'progress' | 'settled' + ) { + const context = lease + if (!context) { + return + } + updateOptionState((current) => + catalog && context.optionCatalog + ? applyStructuredAgentSessionModelCatalog( + current, + context.optionCatalog, + catalog, + context + ) + : settleStructuredAgentSessionBuiltinCatalog(current, false) + ) + if ( + !context.coherentCatalog && + catalog && + catalog.origin !== 'unknown' && + !catalog.listingInProgress + ) { + invalidate() + } + } + } +}