From 2e38249a7d55b626321e48d882d5a5829010e2f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:18:23 -0700 Subject: [PATCH] Keep a chat's accepted model options in what it shows and sends (#31208) * Keep a chat's accepted pick in what it shows and sends A pick held while a new chat is created is committed into the chat's active option record once accepted, through the same step a normal pick takes, and older options reads are dropped. The host also publishes the chat's saved effort, Fast and speed as optional status fields so another device's pick moves the option stamp and re-reads options. * Keep chat picks ordered and capture displayed send selection * fix: keep chat choices in one controller with accepted input capture * fix: pass captured Codex model input to the turn request * Keep structured chat option pickers usable during turns * Preserve logical selection ownership across runtime transitions * Consolidate selection lifecycle contract coverage * Consolidate mobile structured send imports * Preserve legacy action outcomes and opening message identity * Consolidate repeated selection test setup and assertions * Fix option reset admission and preserve uncertain write notices * fix(chat): complete option contract fixture types * test(chat): place affinity integration in cross-layer suite --- ...e-structured-agent-session-host-support.ts | 4 + .../mobile-structured-agent-session-send.ts | 5 +- ...bileNativeChatSessionOptionPickers.test.ts | 91 +++ ...ructured-agent-options.first-frame.test.ts | 7 +- ...-structured-agent-options.lifetime.test.ts | 424 ++++++++++++++ ...ured-agent-options.permission-mode.test.ts | 53 +- ...e-structured-agent-options.test-fixture.ts | 238 ++++++++ ...se-mobile-structured-agent-options.test.ts | 450 ++++++--------- .../use-mobile-structured-agent-options.ts | 197 +++---- ...e-structured-agent-session-queued.test.tsx | 85 +-- ...e-mobile-structured-agent-session.test.tsx | 24 +- .../use-mobile-structured-agent-session.ts | 2 +- .../use-mobile-structured-option-state.ts | 83 ++- .../use-mobile-structured-option-surface.ts | 16 +- ...-mobile-structured-selection-controller.ts | 58 ++ ...use-mobile-structured-send-with-outcome.ts | 12 +- ...-mobile-structured-session-option-stamp.ts | 10 +- ...-structured-effort-default-at-rest.test.ts | 8 +- .../codex-structured-message-options.test.ts | 85 +++ .../runtime-environments-call-routing.test.ts | 1 + ...agent-session-codex-applied-inputs.test.ts | 5 + .../structured-agent-session-create.ts | 26 +- ...uctured-agent-session-execution-options.ts | 32 +- ...structured-agent-session-host-mutations.ts | 1 + ...ured-agent-session-message-choices.test.ts | 3 +- ...structured-agent-session-mutation-plans.ts | 2 + ...ssion-options-read.accepted-inputs.test.ts | 239 ++++++++ ...uctured-agent-session-options-read.test.ts | 151 +++-- .../structured-agent-session-options-read.ts | 33 +- ...tructured-agent-session-queued-messages.ts | 5 +- ...tructured-agent-session-resting-options.ts | 4 +- ...uctured-agent-session-send-options.test.ts | 174 +++++- ...tructured-agent-session-startup-attempt.ts | 3 +- ...d-agent-session-status-summary-equality.ts | 3 + ...structured-agent-session-status-summary.ts | 7 + .../structured-agent-session-turns.ts | 3 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 51 +- ...ChatSessionOptionPickers.mid-turn.test.tsx | 120 ++++ .../NativeChatSessionOptionPickers.test.tsx | 2 +- .../NativeChatSessionOptionPickers.tsx | 2 +- ...tive-chat-session-option-settings-write.ts | 5 +- ...ve-chat-subscriptions.performance.test.tsx | 255 +++++++++ ...ctured-agent-session-option-first-frame.ts | 22 + ...ructured-agent-session-options-contract.ts | 22 +- .../structured-agent-session-pending-sends.ts | 1 + .../structured-agent-session-send-attempt.ts | 1 + .../structured-agent-session-send-slot.ts | 4 +- .../use-host-model-catalog-upgrade.test.tsx | 29 +- ...model-catalog-upgrade.unavailable.test.tsx | 2 +- .../use-native-chat-provisional-launch.ts | 12 +- ...tured-agent-session-context-usage.test.tsx | 4 +- ...e-structured-agent-session-option-state.ts | 203 +++---- ...agent-session-options.first-frame.test.tsx | 29 +- ...t-session-options.learned-default.test.tsx | 3 +- ...t-session-options.permission-mode.test.tsx | 167 ++++-- ...-session-options.preloaded-agents.test.tsx | 2 +- ...red-agent-session-options.test-fixture.tsx | 289 ++++++++++ ...-structured-agent-session-options.test.tsx | 305 ++++------ .../use-structured-agent-session-options.ts | 205 +++---- ...uctured-agent-session-provisional.test.tsx | 15 +- ...ctured-agent-session-send-options.test.tsx | 102 +++- ...e-structured-agent-session-send-options.ts | 42 +- .../use-structured-agent-session-sends.ts | 23 +- ...e-structured-agent-session.rewind.test.tsx | 33 +- .../use-structured-agent-session.test.tsx | 252 ++++---- .../use-structured-agent-session.ts | 3 +- .../use-structured-session-selection-reads.ts | 44 ++ .../QuickLaunchButton.launch-status.test.tsx | 2 +- ...d-agent-session-launch-outcome-tracking.ts | 12 +- ...tructured-agent-session-launch-registry.ts | 22 +- ...ctured-agent-session-launch-reload.test.ts | 8 +- .../structured-agent-session-launch-reload.ts | 6 +- ...-agent-session-launch-cancellation.test.ts | 4 +- .../lib/launch-structured-agent-session.ts | 2 + ...ssion-launch-action-effort.test-fixture.ts | 3 + ...ctured-agent-session-launch-create-call.ts | 6 + ...sion-launch-create-message.test-fixture.ts | 276 +++++++++ ...gent-session-launch-create-message.test.ts | 320 +++++------ ...red-agent-session-launch-create-message.ts | 57 +- ...ructured-agent-session-launch-lifecycle.ts | 19 + ...tured-agent-session-launch-options.test.ts | 136 +++-- ...structured-agent-session-launch-options.ts | 309 +++++----- ...agent-session-launch-prerequisites.test.ts | 402 +++++++++++++ ...ured-agent-session-launch-prerequisites.ts | 137 +++++ .../structured-agent-session-launch.test.ts | 71 +-- .../lib/structured-agent-session-launch.ts | 20 +- ...ured-agent-session-selection-controller.ts | 179 ++++++ ...-agent-session-selection-lifetime.test.tsx | 539 ++++++++++++++++++ .../runtime/runtime-environment-revision.ts | 8 + .../runtime-host-contact-regained.test.ts | 80 +++ .../runtime/runtime-host-contact-regained.ts | 21 +- .../runtime-rpc-selection-order.test.ts | 100 ++++ .../structured-agent-session-client.ts | 10 +- ...t-session-host-capability-scaling.test.tsx | 47 +- ...tured-agent-session-tab-retirement.test.ts | 109 ++++ ...structured-agent-session-tab-retirement.ts | 2 + ...uctured-launch-cancel-layout-owner.test.ts | 2 +- src/renderer/src/store/index.ts | 5 + .../purged-structured-launch-retirement.ts | 2 + .../agent-session-create-capabilities.ts | 4 + src/shared/agent-session-execution-options.ts | 5 + src/shared/agent-session-options-result.ts | 9 +- src/shared/agent-session-wire.ts | 5 + ...uctured-agent-session-agent-params.test.ts | 24 + .../structured-agent-session-params.ts | 9 +- src/shared/structured-agent-session-create.ts | 1 + ...tructured-agent-session-option-snapshot.ts | 4 +- .../structured-agent-session-option-state.ts | 17 +- ...ructured-agent-session-option-view.test.ts | 45 ++ .../structured-agent-session-option-view.ts | 55 +- .../structured-agent-session-options.test.ts | 172 +++++- .../structured-agent-session-options.ts | 38 +- ...ed-agent-session-permission-option.test.ts | 38 ++ ...agent-session-selection-operations.test.ts | 103 ++++ ...ured-agent-session-selection-operations.ts | 275 +++++++++ .../structured-agent-session-send-mutation.ts | 2 + ...nvironment-selection-affinity.unit.test.ts | 187 ++++++ ...permission-report-publication.unit.test.ts | 76 ++- 118 files changed, 6963 insertions(+), 1815 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-options.lifetime.test.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-options.test-fixture.ts create mode 100644 mobile/src/session/use-mobile-structured-selection-controller.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-read.accepted-inputs.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.mid-turn.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-option-first-frame.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.test-fixture.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-session-selection-reads.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-create-message.test-fixture.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-lifecycle.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prerequisites.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prerequisites.ts create mode 100644 src/renderer/src/lib/structured-agent-session-selection-controller.ts create mode 100644 src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx create mode 100644 src/renderer/src/runtime/runtime-rpc-selection-order.test.ts create mode 100644 src/shared/structured-agent-session-selection-operations.test.ts create mode 100644 src/shared/structured-agent-session-selection-operations.ts create mode 100644 tests/e2e/runtime-environment-selection-affinity.unit.test.ts diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 9f84b9ee0c8..d77ea520393 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { + AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' @@ -47,6 +48,8 @@ export type StructuredAgentSessionHostSupport = { // Hosts predating mobile access refuse this feed even if they implement it. statusFeed: boolean quietRepeatedStop: boolean + /** A send may carry the model and effort its turn runs on. */ + sendSelection?: boolean sendOptions: boolean /** Older hosts refuse a send body carrying picked skill paths. */ sendSkills?: boolean @@ -83,6 +86,7 @@ export function structuredAgentSessionHostSupport( ), statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), + sendSelection: capabilities.includes(AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), sendSkills: capabilities.includes(AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY), discoverWorkspaceSkills: capabilities.includes(SKILL_DISCOVER_WORKSPACE_CAPABILITY), diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 30853547c77..7e67badf812 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -29,6 +29,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { skills?: readonly AgentJournalSkillRef[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' + /** Sent only when the host advertises `agent-session.send-selection.v1`. */ + selection?: Readonly> /** Sent only when the host advertises `agent-session.send-options.v1`. */ options?: Readonly> deadline?: number @@ -57,7 +59,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { fields: { body, ...(input.delivery ? { delivery: input.delivery } : {}), - ...(input.options ? { options: input.options } : {}) + ...(input.options ? { options: input.options } : {}), + ...(input.selection !== undefined ? { selection: input.selection } : {}) }, clientOperationId: input.clientMessageId, timeoutMs diff --git a/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts index edd43727ff2..2f1681f0119 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts @@ -1,3 +1,14 @@ +import type { AgentSessionOptionsResult } from '../../../../../src/shared/agent-session-wire' +import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller' +import { + OPTIONS, + BASE, + deferred, + accepted, + optionsClient, + recordingMutate, + mountOptions +} from '../../use-mobile-structured-agent-options.test-fixture' import { createElement, useState, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -168,6 +179,86 @@ describe('MobileNativeChatSessionOptionPickers', () => { renderer = null }) + it('renders actual settled permission/reset picks without busy state and locks ordinary pending model work', async () => { + const idle = deferred() + let reads = 0 + const rpc = optionsClient(() => + ++reads === 1 + ? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } }) + : idle.promise + ) + const replies = Array.from({ length: 3 }, () => deferred>()) + const { calls, mutate } = recordingMutate((index) => replies[index].promise) + const view = await mountOptions({ ...BASE, client: rpc.client, mutate, permissionOwn: true }) + function ActualPickers() { + const options = view.current() + const { nativeChatSessionOptions } = useMobileNativeChatSessionOptionController({ + client: rpc.client, + activeChatStructured: true, + activeSessionTabId: BASE.sessionId, + agent: 'codex', + dispatchCommand: async () => 'accepted', + hostId: 'phone-picker', + isTabChatView: () => true, + isWorking: false, + reportedModel: null, + structured: { + snapshot: options.optionSnapshot, + pendingId: options.pendingOptionId, + setOption: options.setStructuredOption, + invokeAction: options.invokeStructuredOption + }, + toggleTabChatView: () => {}, + worktreeId: 'workspace-1' + }) + return createElement(MobileNativeChatSessionOptionPickers, { + ...nativeChatSessionOptions!, + presentation: { page: 'model', refocus: false }, + onDrawerOpen: () => {}, + onNavigate: () => {} + }) + } + try { + await act(async () => { + renderer = create(createElement(ActualPickers)) + }) + for (const [index, value] of ['full-access', 'own'].entries()) { + let pick!: Promise + act(() => { + pick = view.current().setStructuredOption('permissionMode', value) + }) + expect(calls[index]?.fields).toEqual({ key: 'permissionMode', value }) + expect(view.current().pendingOptionId).toBeNull() + await act(async () => renderer!.update(createElement(ActualPickers))) + expect(findAllByHostType(renderer!.root, 'ActivityIndicator')).toHaveLength(0) + expect(rowByText('GPT Fast').props.accessibilityState?.disabled).toBe(false) + await act(async () => + replies[index].resolve( + accepted({ key: 'permissionMode', value, options: { permissionMode: value } }, true) + ) + ) + await expect(pick).resolves.toBe(true) + expect(view.current().pendingOptionId).toBeNull() + await act(async () => renderer!.update(createElement(ActualPickers))) + expect(findAllByHostType(renderer!.root, 'ActivityIndicator')).toHaveLength(0) + expect(rowByText('GPT Fast').props.accessibilityState?.disabled).toBe(false) + } + act(() => { + void view.current().setStructuredOption('model', 'gpt-fast') + }) + await act(async () => renderer!.update(createElement(ActualPickers))) + expect(rowByText('GPT Live').props.accessibilityState?.disabled).toBe(true) + expect(findAllByHostType(renderer!.root, 'ActivityIndicator')).toHaveLength(1) + expect(calls.map((call) => call.fields)).toEqual([ + { key: 'permissionMode', value: 'full-access' }, + { key: 'permissionMode', value: 'own' }, + { key: 'model', value: 'gpt-fast' } + ]) + } finally { + await view.unmount() + } + }) + it('renders nothing without a model descriptor', () => { mount([]) expect(renderer!.toJSON()).toBeNull() diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index bdf19d31917..093ace91992 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -59,7 +59,12 @@ async function mount(initial: Props) { fence: props.fence, mutate, turnId: null, - hostSupport: { permissionModes: true, coherentModelCatalog: true, statusFeed: false } + hostSupport: { + sendOptions: false, + permissionModes: true, + coherentModelCatalog: true, + statusFeed: false + } }) latest = options frames.push(modelChoices(options.optionSnapshot)) 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 new file mode 100644 index 00000000000..92a9d90b45f --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.lifetime.test.ts @@ -0,0 +1,424 @@ +import * as selectionOperations from '../../../src/shared/structured-agent-session-selection-operations' +import { createElement, StrictMode, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { OptionsParams } from '../../../src/shared/rpc-contract/structured-agent-session-params' +import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' +import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' +import { useMobileStructuredSelectionController } from './use-mobile-structured-selection-controller' +import { + settle, + accepted, + OPTIONS, + BASE, + deferred, + rpcSuccess, + optionsClient, + queuedReads, + recordingMutate, + mountOptions, + currentValueOf +} from './use-mobile-structured-agent-options.test-fixture' + +function acceptedWrite({ fields }: { fields: Record }) { + const { key, value } = fields + if (typeof key !== 'string' || typeof value !== 'string') { + throw new Error('expected a recorded option write') + } + return accepted({ key, value, options: { [key]: value } }, true) +} + +it('keeps actual phone operations and correlated acceptance through a stable-client path migration', async () => { + const stale = deferred() + let oldReads = 0 + const physical = optionsClient(() => + ++oldReads === 1 ? Promise.resolve(OPTIONS) : stale.promise + ) + const currentReads: ReturnType>[] = [] + const next = optionsClient(() => { + const read = deferred() + currentReads.push(read) + return read.promise + }) + const reply = deferred>() + const send = physical.client.sendRequest + physical.client.sendRequest = (method, params, options) => + method === 'agentSession.setOption' ? reply.promise : send(method, params, options) + const client = createStableLogicalRpcClient(physical.client, 'lan') + const mutate: StructuredAgentSessionMutate = async ( + method: string, + fingerprintMethod: string, + fields: Record + ) => { + const result = await requestStructuredAgentSessionMutation({ + client, + method, + fingerprintMethod, + fields, + sessionId: BASE.sessionId, + expectedRuntimeFence: 1 + }) + return result.status === 'accepted' + ? { status: 'accepted', value: result.value, sameFence: false } + : result.status === 'unknown' + ? { status: 'unknown' } + : { status: 'rejected' } + } + const view = await mountOptions({ ...BASE, client, mutate, sendSelection: true }) + let pick!: Promise + act(() => { + pick = view.current().setStructuredOption('model', 'gpt-fast') + }) + await view.rerender({ optionStamp: 'old-query' }) + await act(async () => { + await client.migrateTo(next.client, 'relay') + }) + await settle() + await act(async () => stale.resolve({ ...OPTIONS, conversationCommands: ['compact'] })) + expect(view.current().conversationCommands).toEqual([]) + expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-fast') + expect(view.current().sendSelection()).toMatchObject({ selection: { model: 'gpt-fast' } }) + await act(async () => + reply.resolve(rpcSuccess({ ok: true, value: { options: { model: 'gpt-fast' } } })) + ) + await expect(pick).resolves.toBe(true) + expect(next.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(1) + // The first current query began before acceptance; its trailing query captures the terminal ID. + await act(async () => currentReads[0].resolve(OPTIONS)) + await settle() + await act(async () => currentReads.at(-1)!.resolve(OPTIONS)) + await settle() + expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-live') + await view.unmount() + client.close() +}) + +it.each(['session', 'client'] as const)( + 'ends actual %s scope and cleanup replay without reviving setup-time work', + async (boundary) => { + const firstClient = optionsClient(queuedReads(OPTIONS)) + const replacement = optionsClient(queuedReads(OPTIONS)) + let current!: selectionOperations.StructuredSessionSelectionController + const setups: { + controller: selectionOperations.StructuredSessionSelectionController + operation: selectionOperations.SelectionOperation + }[] = [] + let cleanups = 0 + function Scope({ sessionId, client }: { sessionId: string; client: RpcClient }) { + const { controller } = useMobileStructuredSelectionController({ + agent: 'codex', + client, + sessionId, + onRevalidate: () => {} + }) + current = controller + useEffect(() => { + setups.push({ controller, operation: controller.issue('model', 'gpt-live', null) }) + return () => { + cleanups++ + } + }, [controller]) + return null + } + const element = (sessionId: string, client: RpcClient) => + createElement(StrictMode, null, createElement(Scope, { sessionId, client })) + let root!: ReactTestRenderer + await act(async () => { + root = create(element('scope-A', firstClient.client)) + }) + expect(setups).toHaveLength(2) + expect(cleanups).toBe(1) + const old = setups[0] + await expect(old.operation.result).resolves.toEqual({ kind: 'superseded' }) + act(() => + old.controller.settle(old.operation, { kind: 'accepted', options: { model: 'gpt-live' } }) + ) + expect(firstClient.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(0) + const live = setups[1] + act(() => + live.controller.settle(live.operation, { kind: 'accepted', options: { model: 'gpt-live' } }) + ) + await settle() + expect(firstClient.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(1) + const first = current + const held = first.issue('model', 'gpt-fast', null) + await act(async () => + root.update( + element( + boundary === 'session' ? 'scope-B' : 'scope-A', + boundary === 'client' ? replacement.client : firstClient.client + ) + ) + ) + await expect(held.result).resolves.toEqual({ kind: 'superseded' }) + act(() => first.settle(held, { kind: 'accepted', options: { model: 'gpt-fast' } })) + await expect(first.issue('model', 'dead', null).result).resolves.toEqual({ kind: 'superseded' }) + expect(current).not.toBe(first) + const latest = setups.at(-1)! + act(() => + latest.controller.settle(latest.operation, { + kind: 'accepted', + options: { model: 'gpt-live' } + }) + ) + await settle() + expect(firstClient.methods('settings.mutateNativeChatSessionOptions')).toHaveLength( + boundary === 'session' ? 2 : 1 + ) + expect(replacement.methods('settings.mutateNativeChatSessionOptions')).toHaveLength( + boundary === 'client' ? 1 : 0 + ) + const last = current + const unmounted = last.issue('model', 'gpt-fast', null) + await act(async () => root.unmount()) + await expect(unmounted.result).resolves.toEqual({ kind: 'superseded' }) + await expect(last.issue('model', 'dead', null).result).resolves.toEqual({ kind: 'superseded' }) + } +) + +it('reads the new physical path after an idle connected-to-connected migration without an operation wake', async () => { + const old = optionsClient(queuedReads(OPTIONS)) + const answers: ReturnType>[] = [] + let cause = 'connected-callback' + const causes: string[] = [] + const next = optionsClient(() => { + causes.push(cause) + const answer = deferred() + answers.push(answer) + return answer.promise + }) + const client = createStableLogicalRpcClient(old.client, 'lan') + const { mutate } = recordingMutate(async () => ({ status: 'rejected' })) + const view = await mountOptions({ ...BASE, client, mutate }) + expect(old.optionReads()).toBe(1) + expect(view.current().conversationCommands).toEqual([]) + await act(async () => { + await client.migrateTo(next.client, 'relay') + cause = 'generation-read-effect' + }) + await settle() + expect(next.optionReads()).toBeGreaterThanOrEqual(1) + expect(next.optionReads()).toBeLessThanOrEqual(2) + // The contact callback can start one read before React installs the generation-keyed reader. + expect(causes.filter((entry) => entry === 'connected-callback').length).toBeLessThanOrEqual(1) + expect(causes.filter((entry) => entry === 'generation-read-effect')).toHaveLength(1) + await act(async () => { + for (const answer of answers) { + answer.resolve({ ...OPTIONS, conversationCommands: ['compact'] }) + } + }) + await settle() + expect(view.current().conversationCommands).toEqual(['compact']) + expect(next.methods('agentSession.setOption')).toHaveLength(0) + await view.unmount() + client.close() +}) + +it.each(['current', 'failed-then-wake', 'true-end'] as const)( + 'settles a late refusal after a physical reset without losing progress (%s)', + async (schedule) => { + const old = optionsClient(queuedReads(OPTIONS)) + const reads: ReturnType>[] = [] + let cause = 'connected-callback' + const causes: string[] = [] + const next = optionsClient(() => { + causes.push(cause) + const read = Promise.withResolvers() + reads.push(read) + return read.promise + }) + const client = createStableLogicalRpcClient(old.client, 'lan') + const reply = deferred<{ status: 'rejected' }>() + const { calls, mutate } = recordingMutate(() => reply.promise) + const view = await mountOptions({ ...BASE, client, mutate, sendSelection: true }) + let picking!: Promise + act(() => { + picking = view.current().setStructuredOption('model', 'gpt-fast') + }) + await act(async () => { + await client.migrateTo(next.client, 'relay') + cause = 'generation-read-effect' + }) + const current: AgentSessionOptionsResult = { + ...OPTIONS, + current: { model: 'gpt-live', effort: 'high' }, + conversationCommands: ['compact'] + } + await act(async () => { + for (const read of reads) { + if (schedule === 'failed-then-wake') { + read.reject(new Error('offline')) + } else { + read.resolve(current) + } + } + }) + if (schedule === 'true-end') { + cause = 'replacement-session-read' + await view.rerender({ sessionId: 'replacement' }) + } + const readsBeforeReply = next.optionReads() + cause = 'late-result-revalidation' + await act(async () => reply.resolve({ status: 'rejected' })) + await expect(picking).resolves.toBe(schedule === 'true-end') + expect(view.current().pendingOptionId).toBeNull() + expect(calls).toHaveLength(1) + expect(next.optionReads()).toBeLessThanOrEqual(schedule === 'current' ? 2 : 3) + const requested = next.methods('agentSession.options').map(({ params }, index) => ({ + ...OptionsParams.parse(params), + cause: causes[index] + })) + expect(requested).toHaveLength(causes.length) + // Migration and replacement install different session readers; each trigger issues once. + for (const reason of [ + 'connected-callback', + 'generation-read-effect', + 'replacement-session-read', + 'late-result-revalidation' + ]) { + expect(causes.filter((entry) => entry === reason).length).toBeLessThanOrEqual(1) + } + expect(causes).toContain('generation-read-effect') + for (const request of requested) { + expect(request.sessionId).toBe( + request.cause === 'replacement-session-read' ? 'replacement' : BASE.sessionId + ) + } + if (schedule === 'true-end') { + expect(requested.filter((request) => request.cause === 'replacement-session-read')).toEqual([ + { sessionId: 'replacement', cause: 'replacement-session-read' } + ]) + expect(next.optionReads()).toBe(readsBeforeReply) + expect(causes).not.toContain('late-result-revalidation') + } + expect(next.methods('settings.mutateNativeChatSessionOptions')).toHaveLength(0) + if (schedule === 'failed-then-wake') { + expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-live') + const beforeWake = reads.length + cause = 'status-wake' + await view.rerender({ optionStamp: 'legitimate-status-wake' }) + expect(reads.length).toBe(beforeWake + 1) + await act(async () => reads.at(-1)!.resolve(current)) + } + if (schedule !== 'true-end') { + expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-live') + expect(view.current().conversationCommands).toEqual(['compact']) + expect(view.current().sendSelection()).toMatchObject({ + selection: { model: 'gpt-live', effort: 'high' } + }) + } else { + expect(view.current().sendSelection()).toEqual({ selection: {} }) + } + await view.unmount() + client.close() + } +) + +it.each([false, true])( + 'never promotes permission or older model busy state after settlement (permission first=%s)', + async (permissionFirst) => { + const idle = deferred() + let reads = 0 + const rpc = optionsClient(() => + ++reads === 1 + ? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } }) + : idle.promise + ) + const replies = [ + deferred>(), + deferred>() + ] + const { calls, mutate } = recordingMutate((index) => replies[index].promise) + const view = await mountOptions({ ...BASE, client: rpc.client, mutate, sendSelection: true }) + act(() => { + void view.current().setStructuredOption('model', 'gpt-fast') + }) + await act(async () => + expect(await view.current().setStructuredOption('model', 'gpt-live')).toBe(false) + ) + act(() => { + void view.current().setStructuredOption('permissionMode', 'full-access') + }) + expect(view.current().pendingOptionId).toBeNull() + for (const index of permissionFirst ? [1, 0] : [0, 1]) { + await act(async () => replies[index].resolve(acceptedWrite(calls[index]))) + expect(view.current().pendingOptionId).toBeNull() + expect(currentValueOf(view.current().optionSnapshot, 'model')).toBe('gpt-fast') + expect(currentValueOf(view.current().optionSnapshot, 'permissionMode')).toBe('full-access') + expect(view.current().sendSelection()).toMatchObject({ selection: { model: 'gpt-fast' } }) + } + expect(calls.map((call) => call.fields)).toEqual([ + { key: 'model', value: 'gpt-fast' }, + { key: 'permissionMode', value: 'full-access' } + ]) + await view.unmount() + } +) + +it('bounds real phone permission projection work with 10 and 100 retained operations', async () => { + const measurements: number[] = [] + for (const count of [10, 100]) { + const factory = vi.spyOn(selectionOperations, 'createStructuredSessionSelectionController') + const blocked = deferred() + let reads = 0 + const rpc = optionsClient(() => + ++reads === 1 + ? Promise.resolve({ ...OPTIONS, current: { ...OPTIONS.current, permissionMode: 'ask' } }) + : blocked.promise + ) + const replies = Array.from({ length: count }, () => deferred>()) + const { calls, mutate } = recordingMutate((index) => replies[index].promise) + const view = await mountOptions({ ...BASE, client: rpc.client, mutate }) + const result = factory.mock.results.at(-1)! + if (result.type !== 'return') { + throw new Error('real hook did not construct its owner') + } + const controller = result.value + const issue = vi.spyOn(controller, 'issue') + let visits = 0 + for (let index = 0; index < count; index++) { + const before = visits + act(() => { + void view.current().setStructuredOption('permissionMode', index % 2 ? 'ask' : 'full-access') + }) + expect(visits - before).toBeLessThanOrEqual(40) + const issued = issue.mock.results.at(-1)! + if (issued.type !== 'return') { + throw new Error('permission not admitted') + } + const operation = issued.value + for (const key of ['identity', 'key', 'transmitted', 'outcome'] as const) { + let value = operation[key] + Object.defineProperty(operation, key, { + configurable: true, + get() { + visits++ + return value + }, + set(next) { + value = next + } + }) + } + } + const issueVisits = visits + expect(issueVisits).toBeLessThanOrEqual(40 * count) + visits = 0 + for (let index = count - 1; index >= 0; index--) { + const before = visits + await act(async () => replies[index].resolve(acceptedWrite(calls[index]))) + expect(view.current().pendingOptionId).toBeNull() + expect(visits - before).toBeLessThanOrEqual(40) + } + expect(calls).toHaveLength(count) + expect(visits).toBeLessThanOrEqual(40 * count) + measurements.push(issueVisits + visits) + await view.unmount() + issue.mockRestore() + factory.mockRestore() + } + expect(measurements[1]).toBeLessThanOrEqual(measurements[0] * 11) +}) 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 6881bcc5814..af475729f33 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,9 +1,15 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' -import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import type { + StructuredAgentSessionMutate, + StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' function optionsFor(permissionMode?: string): AgentSessionOptionsResult { @@ -71,6 +77,7 @@ async function mountOptions(props: ProbeProps) { probe.hostAdvertisesPermissionModes === null ? null : { + sendOptions: false, permissionModes: probe.hostAdvertisesPermissionModes ?? true, permissionOwn: probe.permissionOwn, statusFeed: false @@ -89,6 +96,7 @@ async function mountOptions(props: ProbeProps) { }) await settle() return { + pending: () => rendered.current?.pendingOptionId, offered: () => { const entry = rendered.current?.optionSnapshot.find( (descriptor) => descriptor.id === 'permissionMode' @@ -107,11 +115,15 @@ async function mountOptions(props: ProbeProps) { }) await settle() }, - pick: async (mode: string) => { + pick: async (mode: string, waitForResult = false) => { + let result!: Promise await act(async () => { - void rendered.current?.setStructuredOption('permissionMode', mode) + result = rendered.current!.setStructuredOption('permissionMode', mode) }) await settle() + if (waitForResult) { + return result + } }, unmount: () => act(async () => renderer?.unmount()) } @@ -128,6 +140,9 @@ describe('the permission pill against a host predating per-chat modes', () => { }) expect(options.permission()).toBe('host') expect(options.offered()).toEqual(['host']) + vi.mocked(mutate).mockClear() + expect(await options.pick('own', true)).toBe(false) + expect(mutate).not.toHaveBeenCalled() await options.unmount() }) @@ -151,6 +166,9 @@ describe('the permission pill against a host predating per-chat modes', () => { hostAdvertisesPermissionModes: null }) expect(options.permission()).toBeNull() + vi.mocked(mutate).mockClear() + expect(await options.pick('own', true)).toBe(false) + expect(mutate).not.toHaveBeenCalled() await options.unmount() }) }) @@ -218,14 +236,39 @@ describe('the permission mode on the phone', () => { }) it('shows a pick at once while its write waits behind a start, and stays pickable', async () => { - vi.mocked(mutate).mockImplementation(() => new Promise(() => {})) + const first = + Promise.withResolvers>() + const second = + Promise.withResolvers>() + vi.mocked(mutate) + .mockReset() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) const rpc = optionsClient('ask') const options = await mountOptions({ client: rpc.client, fence: 1, optionStamp: '' }) + const send = rpc.client.sendRequest + rpc.client.sendRequest = (method, params, config) => + method === 'agentSession.options' ? new Promise(() => {}) : send(method, params, config) await options.pick('full-access') expect(options.permission()).toBe('full-access') await options.pick('auto') expect(options.permission()).toBe('auto') expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2) + for (const [reply, value] of [ + [second, 'auto'], + [first, 'full-access'] + ] as const) { + await act(async () => + reply.resolve({ + status: 'accepted', + value: { key: 'permissionMode', value, options: { permissionMode: value } }, + sameFence: true + }) + ) + expect(options.pending()).toBeNull() + expect(options.offered()).toContain('auto') + expect(options.permission()).toBe('auto') + } await options.unmount() }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-options.test-fixture.ts new file mode 100644 index 00000000000..9f963900b08 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.test-fixture.ts @@ -0,0 +1,238 @@ +import { createElement, useLayoutEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { vi } from 'vitest' +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat/input/native-chat-session-options' +import type { RpcClient } from '../transport/rpc-client' +import type { + StructuredAgentSessionMutate, + StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' +import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' + +const OPTIONS: AgentSessionOptionsResult = { + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-fast', + label: 'GPT Fast', + isDefault: false, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' } + ] + } + ], + current: { model: 'gpt-live', effort: 'medium' } +} + +const FAST_OPTIONS: AgentSessionOptionsResult = { + ...OPTIONS, + models: OPTIONS.models.map((model) => ({ ...model, supportsFastMode: true })), + fastModeSupport: { supported: true }, + current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] } +} + +const FAST_OPTIONS_ON: AgentSessionOptionsResult = { + ...FAST_OPTIONS, + current: { ...FAST_OPTIONS.current, fastMode: true } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +function rpcSuccess(result: unknown) { + return { id: 'rpc-1', ok: true as const, result, _meta: { runtimeId: 'runtime-1' } } +} + +type SentRequest = { method: string; params: unknown } + +function optionsClient( + reads: () => Promise, + catalog?: (params: unknown) => Promise +) { + const sent: SentRequest[] = [] + const client: RpcClient = { + sendRequest: async (method: string, params?: unknown) => { + sent.push({ method, params }) + if (method === 'agentSession.modelCatalog') { + // Without a scripted catalog, the host predates the method. + return catalog + ? rpcSuccess(await catalog(params)) + : { + id: 'rpc-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return rpcSuccess(method === 'agentSession.options' ? await reads() : {}) + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + const methods = (name: string) => sent.filter((entry) => entry.method === name) + return { client, sent, methods, optionReads: () => methods('agentSession.options').length } +} + +function queuedReads(...results: AgentSessionOptionsResult[]) { + let index = 0 + return () => Promise.resolve(results[Math.min(index++, results.length - 1)]!) +} + +type MutateCall = { method: string; fields: Record } + +// Use an untyped mock: a concrete fixture cannot satisfy arbitrary TValue from the generic mutation caller. +function recordingMutate( + next: (call: number) => Promise> +) { + const calls: MutateCall[] = [] + const mock = vi.fn() + mock.mockImplementation( + (method: string, _fingerprintMethod: string, fields: Record) => { + calls.push({ method, fields }) + return next(calls.length - 1) + } + ) + const mutate: StructuredAgentSessionMutate = mock + return { calls, mutate } +} + +function accepted( + value: AgentSessionOptionResult, + sameFence: boolean +): StructuredAgentSessionMutationResult { + return { status: 'accepted', value, sameFence } +} + +export type Controller = ReturnType + +type ProbeProps = { + agent: string | null + client: RpcClient | null + sessionId: string | null + fence: number | null + mutate: StructuredAgentSessionMutate + optionStamp?: string + turnId?: string | null + sendSelection?: boolean + coherentCatalog?: boolean + permissionOwn?: boolean + onRender: (controller: Controller) => void +} + +function Probe(props: ProbeProps): null { + const controller = useMobileStructuredAgentOptions({ + agent: props.agent, + client: props.client, + sessionId: props.sessionId, + enabled: true, + fence: props.fence, + mutate: props.mutate, + turnId: props.turnId ?? null, + ...(props.optionStamp ? { optionStamp: props.optionStamp } : {}), + hostSupport: { + permissionModes: true, + permissionOwn: props.permissionOwn, + sendOptions: false, + statusFeed: false, + sendSelection: props.sendSelection, + coherentModelCatalog: props.coherentCatalog + } + }) + useLayoutEffect(() => props.onRender(controller), [controller, props.onRender]) + return null +} + +async function settle() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function mountOptions(props: Omit) { + let rendered: Controller | null = null + const onRender = (controller: Controller) => { + rendered = controller + } + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(Probe, { ...props, onRender })) + }) + await settle() + const current = (): Controller => { + if (!rendered) { + throw new Error('probe never rendered') + } + return rendered + } + return { + current, + rerender: async (next: Partial>) => { + await act(async () => { + renderer?.update(createElement(Probe, { ...props, ...next, onRender })) + }) + await settle() + }, + unmount: async () => { + await act(async () => renderer?.unmount()) + } + } +} + +function descriptorFor( + snapshot: readonly SessionOptionDescriptor[], + id: string +): SessionOptionDescriptor | undefined { + return snapshot.find((entry) => entry.id === id) +} + +function currentValueOf(snapshot: readonly SessionOptionDescriptor[], id: string) { + const kind = descriptorFor(snapshot, id)?.kind + return kind && 'currentValue' in kind ? kind.currentValue : undefined +} + +const BASE = { agent: 'codex', sessionId: 'session-1', fence: 1 } as const + +export { + settle, + OPTIONS, + FAST_OPTIONS, + FAST_OPTIONS_ON, + deferred, + rpcSuccess, + optionsClient, + queuedReads, + recordingMutate, + accepted, + mountOptions, + descriptorFor, + currentValueOf, + BASE +} diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index e1e3542ef7b..fc05245019d 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -1,215 +1,28 @@ -import { createElement, useLayoutEffect } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { act } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionModelCatalogResult, AgentSessionOptionResult, AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' -import type { SessionOptionDescriptor } from '../../../src/shared/native-chat/input/native-chat-session-options' -import type { RpcClient } from '../transport/rpc-client' -import type { - StructuredAgentSessionMutate, - StructuredAgentSessionMutationResult -} from './mobile-structured-agent-session-rpc' -import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' +import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' - -const OPTIONS: AgentSessionOptionsResult = { - models: [ - { - id: 'gpt-live', - label: 'GPT Live', - isDefault: true, - defaultEffort: 'medium', - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - }, - { - id: 'gpt-fast', - label: 'GPT Fast', - isDefault: false, - defaultEffort: 'low', - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' } - ] - } - ], - current: { model: 'gpt-live', effort: 'medium' } -} - -const FAST_OPTIONS: AgentSessionOptionsResult = { - ...OPTIONS, - models: OPTIONS.models.map((model) => ({ ...model, supportsFastMode: true })), - fastModeSupport: { supported: true }, - current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] } -} - -const FAST_OPTIONS_ON: AgentSessionOptionsResult = { - ...FAST_OPTIONS, - current: { ...FAST_OPTIONS.current, fastMode: true } -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((accept) => { - resolve = accept - }) - return { promise, resolve } -} - -function rpcSuccess(result: unknown) { - return { id: 'rpc-1', ok: true as const, result, _meta: { runtimeId: 'runtime-1' } } -} - -type SentRequest = { method: string; params: unknown } - -function optionsClient( - reads: () => Promise, - catalog?: (params: unknown) => Promise -) { - const sent: SentRequest[] = [] - const client: RpcClient = { - sendRequest: async (method: string, params?: unknown) => { - sent.push({ method, params }) - if (method === 'agentSession.modelCatalog') { - // Without a scripted catalog, the host predates the method. - return catalog - ? rpcSuccess(await catalog(params)) - : { - id: 'rpc-1', - ok: false as const, - error: { code: 'method_not_found', message: 'Unknown method' }, - _meta: { runtimeId: 'runtime-1' } - } - } - return rpcSuccess(method === 'agentSession.options' ? await reads() : {}) - }, - subscribe: () => () => {}, - updateTerminalSubscriptionViewport: () => {}, - getState: () => 'connected', - getReconnectAttempt: () => 0, - getLastConnectedAt: () => null, - onStateChange: () => () => {}, - notifyForeground: () => {}, - close: () => {} - } - const methods = (name: string) => sent.filter((entry) => entry.method === name) - return { client, sent, methods, optionReads: () => methods('agentSession.options').length } -} - -function queuedReads(...results: AgentSessionOptionsResult[]) { - let index = 0 - return () => Promise.resolve(results[Math.min(index++, results.length - 1)]!) -} - -type MutateCall = { method: string; fields: Record } - -// Use an untyped mock: a concrete fixture cannot satisfy arbitrary TValue from the generic mutation caller. -function recordingMutate( - next: (call: number) => Promise> -) { - const calls: MutateCall[] = [] - const mock = vi.fn() - mock.mockImplementation( - (method: string, _fingerprintMethod: string, fields: Record) => { - calls.push({ method, fields }) - return next(calls.length - 1) - } - ) - const mutate: StructuredAgentSessionMutate = mock - return { calls, mutate } -} - -function accepted( - value: AgentSessionOptionResult, - sameFence: boolean -): StructuredAgentSessionMutationResult { - return { status: 'accepted', value, sameFence } -} - -type Controller = ReturnType - -type ProbeProps = { - agent: string | null - client: RpcClient | null - sessionId: string | null - fence: number | null - mutate: StructuredAgentSessionMutate - optionStamp?: string - turnId?: string | null - onRender: (controller: Controller) => void -} - -function Probe(props: ProbeProps): null { - const controller = useMobileStructuredAgentOptions({ - agent: props.agent, - client: props.client, - sessionId: props.sessionId, - enabled: true, - fence: props.fence, - mutate: props.mutate, - turnId: props.turnId ?? null, - ...(props.optionStamp ? { optionStamp: props.optionStamp } : {}), - hostSupport: { permissionModes: true, statusFeed: false } - }) - useLayoutEffect(() => props.onRender(controller), [controller, props.onRender]) - return null -} - -async function settle() { - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) -} - -async function mountOptions(props: Omit) { - let rendered: Controller | null = null - const onRender = (controller: Controller) => { - rendered = controller - } - let renderer: ReactTestRenderer | null = null - await act(async () => { - renderer = create(createElement(Probe, { ...props, onRender })) - }) - await settle() - const current = (): Controller => { - if (!rendered) { - throw new Error('probe never rendered') - } - return rendered - } - return { - current, - rerender: async (next: Partial>) => { - await act(async () => { - renderer?.update(createElement(Probe, { ...props, ...next, onRender })) - }) - await settle() - }, - unmount: async () => { - await act(async () => renderer?.unmount()) - } - } -} - -function descriptorFor( - snapshot: readonly SessionOptionDescriptor[], - id: string -): SessionOptionDescriptor | undefined { - return snapshot.find((entry) => entry.id === id) -} - -function currentValueOf(snapshot: readonly SessionOptionDescriptor[], id: string) { - const kind = descriptorFor(snapshot, id)?.kind - return kind && 'currentValue' in kind ? kind.currentValue : undefined -} - -const BASE = { agent: 'codex', sessionId: 'session-1', fence: 1 } as const +import { + type Controller, + settle, + OPTIONS, + FAST_OPTIONS, + FAST_OPTIONS_ON, + deferred, + optionsClient, + queuedReads, + recordingMutate, + accepted, + mountOptions, + descriptorFor, + currentValueOf, + BASE +} from './use-mobile-structured-agent-options.test-fixture' describe('useMobileStructuredAgentOptions fast mode', () => { it('round-trips a boolean fastMode pick as the wire string and remembers the decoded pick', async () => { @@ -269,69 +82,42 @@ describe('useMobileStructuredAgentOptions fast mode', () => { await harness.unmount() }) - it('offers no Fast row when the session reports fast mode unsupported', async () => { - const client = optionsClient( - queuedReads({ ...FAST_OPTIONS, fastModeSupport: { supported: false, reason: 'account' } }) - ) + it.each([ + [ + 'session reports unsupported', + { ...FAST_OPTIONS, fastModeSupport: { supported: false, reason: 'account' } } + ], + ['model capability is unknown', { ...FAST_OPTIONS, models: OPTIONS.models }] + ] as const)('offers no Fast row when %s', async (_reason, answer) => { + const client = optionsClient(queuedReads(answer)) const { mutate } = recordingMutate(async () => ({ status: 'rejected' })) const harness = await mountOptions({ ...BASE, client: client.client, mutate }) - - expect(descriptorFor(harness.current().optionSnapshot, 'fastMode')).toBeUndefined() - await harness.unmount() - }) - - it('offers no Fast row when the model capability is unknown', async () => { - const client = optionsClient( - queuedReads({ - ...FAST_OPTIONS, - models: OPTIONS.models - }) - ) - const { mutate } = recordingMutate(async () => ({ status: 'rejected' })) - const harness = await mountOptions({ ...BASE, client: client.client, mutate }) - expect(descriptorFor(harness.current().optionSnapshot, 'fastMode')).toBeUndefined() await harness.unmount() }) }) describe('useMobileStructuredAgentOptions post-write refresh', () => { - it('reads options back after an accepted same-fence write and applies the refreshed value', async () => { - const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON)) - const { mutate } = recordingMutate(async () => - accepted({ key: 'fastMode', value: 'true', options: {} }, true) - ) - const harness = await mountOptions({ ...BASE, client: client.client, mutate }) - - expect(client.optionReads()).toBe(1) - expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(false) - - await act(async () => { - await harness.current().setStructuredOption('fastMode', true) - }) - await settle() - - expect(client.optionReads()).toBe(2) - expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(true) - await harness.unmount() - }) - - it('skips the refresh when the write landed against a different fence', async () => { - const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON)) - const { mutate } = recordingMutate(async () => - accepted({ key: 'fastMode', value: 'true', options: {} }, false) - ) - const harness = await mountOptions({ ...BASE, client: client.client, mutate }) - - await act(async () => { - await harness.current().setStructuredOption('fastMode', true) - }) - await settle() - - expect(client.optionReads()).toBe(1) - expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(false) - await harness.unmount() - }) + it.each([true, false])( + 'revalidates an accepted operation with sameFence=%s', + async (sameFence) => { + const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON)) + // The reply settles the operation; only the query installs the chat's selection. + const { mutate } = recordingMutate(async () => + accepted({ key: 'fastMode', value: 'true', options: {} }, sameFence) + ) + const harness = await mountOptions({ ...BASE, client: client.client, mutate }) + expect(client.optionReads()).toBe(1) + expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(false) + await act(async () => { + await harness.current().setStructuredOption('fastMode', true) + }) + await settle() + expect(client.optionReads()).toBe(2) + expect(currentValueOf(harness.current().optionSnapshot, 'fastMode')).toBe(true) + await harness.unmount() + } + ) }) describe('useMobileStructuredAgentOptions reads', () => { @@ -709,23 +495,149 @@ describe('useMobileStructuredAgentOptions after a lost pick reply', () => { : null } - it('keeps the confirmed model after a lost pick reply, as desktop does', async () => { - const client = optionsClient(() => Promise.resolve(answer('m1', 'priority'))) + it('draws the tier the host reconciled to, stays usable through a failed read, and takes the reconnect read', async () => { + const pendingRead = deferred() + const reads = [ + () => Promise.resolve(answer('m1', 'priority')), + () => Promise.reject(new Error('terminal reconciliation offline')), + () => Promise.reject(new Error('status reconciliation offline')), + () => pendingRead.promise, + () => Promise.resolve(answer('m2', 'flex')) + ] + const client = optionsClient(() => + (reads.shift() ?? (() => Promise.resolve(answer('m2', 'flex'))))() + ) const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) const harness = await mountOptions({ ...BASE, client: client.client, mutate }) - - let picked = true - await act(async () => { - picked = await harness.current().setStructuredOption('model', 'm2') - }) - expect(picked).toBe(false) - expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m1') expect(speed(harness.current())?.value).toBe('priority') + + // The host switched and set Standard, but its reply never arrived. + await act(async () => { + await harness.current().setStructuredOption('model', 'm2') + }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2') + expect(speed(harness.current())).toEqual({ value: 'default', choices: ['default', 'flex'] }) expect(harness.current().pendingOptionId).toBeNull() + + expect(client.optionReads()).toBe(2) // The terminal wake consumed its failed query. + // The status-triggered read fails independently: nothing changes. + await harness.rerender({ optionStamp: 'status-1' }) + expect(speed(harness.current())?.value).toBe('default') + + // A read in flight when the user picks Flex answers for the old state and is dropped. + await harness.rerender({ optionStamp: 'status-2' }) + let picked = false + await act(async () => { + picked = await harness.current().setStructuredOption('serviceTier', 'flex') + }) + expect(picked).toBe(true) + await act(async () => pendingRead.resolve(answer('m1', 'priority'))) + await settle() + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2') + expect(speed(harness.current())?.value).toBe('flex') + + // Reconnected, the host's answer stands. + await harness.rerender({ optionStamp: 'status-3' }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('m2') + expect(speed(harness.current())?.value).toBe('flex') + expect(client.optionReads()).toBe(6) await harness.unmount() }) }) +it('phone retains a queried seed-catalog base across its fence until later metadata arrives', async () => { + const catalog = deferred() + let reads = 0 + const client = optionsClient( + () => + ++reads === 1 + ? Promise.resolve({ + ...OPTIONS, + current: { + model: 'gpt-live', + effort: 'high', + defaulted: ['model'], + acceptedInputSources: { effort: 'saved' } + } + }) + : deferred().promise, + () => catalog.promise + ) + const { mutate } = recordingMutate(async () => accepted({ key: 'effort', value: 'low' }, true)) + rememberMobileCreatedStructuredSession('answered-retention', 'worktree') + const harness = await mountOptions({ + ...BASE, + sessionId: 'answered-retention', + client: client.client, + mutate, + sendSelection: true, + coherentCatalog: true + }) + expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } }) + await harness.rerender({ fence: 2 }) + expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } }) + await act(async () => + catalog.resolve({ + origin: 'probe', + fetchedAt: 1, + models: OPTIONS.models, + listingNamesConfiguredModel: true + }) + ) + await settle() + expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } }) + await harness.unmount() +}) + +it('phone captures accepted echoed inputs, preserves false, and freezes empty vs legacy presence', async () => { + let answer: AgentSessionOptionsResult = { + ...FAST_OPTIONS, + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + supportsFastMode: true, + efforts: [{ value: 'high', label: 'High' }] + } + ], + current: { + model: 'opus', + effort: 'high', + fastMode: false, + confirmed: ['fastMode'], + acceptedInputSources: {} + } + } + const client = optionsClient(async () => answer) + const { mutate } = recordingMutate(async () => accepted({ key: 'model', value: 'opus' }, true)) + const harness = await mountOptions({ + ...BASE, + agent: 'claude', + client: client.client, + mutate, + sendSelection: true + }) + expect(harness.current().sendSelection()).toEqual({ selection: {} }) + answer = { + ...answer, + current: { ...answer.current, acceptedInputSources: { model: 'saved', fastMode: 'policy' } } + } + await harness.rerender({ optionStamp: 'saved-origin' }) + await settle() + const captured = harness.current().sendSelection() + expect(captured).toEqual({ selection: { model: 'opus', fastMode: 'false' } }) + answer = { + ...answer, + current: { ...answer.current, effort: 'high', acceptedInputSources: { effort: 'saved' } } + } + await harness.rerender({ optionStamp: 'other-client' }) + await settle() + expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } }) + expect(captured).toEqual({ selection: { model: 'opus', fastMode: 'false' } }) + await harness.unmount() +}) + it('refuses a typed literal at an unresolved fence and accepts it when that fence is ready with no menu', async () => { const pending = deferred() let reads = 0 diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index fa4943dec91..0828c03de0f 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -7,14 +7,13 @@ import type { AgentSessionOptionResult } from '../../../src/shared/agent-session import type { SessionOptionValue } from '../../../src/shared/native-chat/input/native-chat-session-options' import { canSetStructuredAgentSessionOption, - commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks } from '../../../src/shared/structured-agent-session-options' import { structuredAgentSessionOptionView, structuredAgentSessionSendSelection, + structuredAgentSessionMessageSelection, structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' import { @@ -25,17 +24,9 @@ import { legacyHostPermissionDescriptor } from '../../../src/shared/structured-a import type { RpcClient } from '../transport/rpc-client' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' -import { persistMobileStructuredOptionPicks } from './native-chat/input/mobile-native-chat-session-option-persistence' import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade' import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' -import { - beginStructuredOptionPick, - isLatestStructuredOptionPick, - settleStructuredOptionPick, - structuredOptionPickCommit, - structuredOptionPickBlockingId -} from '../../../src/shared/structured-agent-session-option-pick-book' import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' const NO_COMMANDS: readonly AgentSessionConversationCommand[] = [] @@ -46,12 +37,17 @@ export function useMobileStructuredAgentOptions(args: { sessionId: string | null enabled: boolean fence: number | null + turnId: string | null mutate: StructuredAgentSessionMutate optionStamp?: string - turnId: string | null hostSupport: Pick< StructuredAgentSessionHostSupport, - 'permissionModes' | 'permissionOwn' | 'coherentModelCatalog' | 'statusFeed' + | 'permissionModes' + | 'permissionOwn' + | 'coherentModelCatalog' + | 'statusFeed' + | 'sendSelection' + | 'sendOptions' > | null }) { const { agent, client, enabled, fence, mutate, optionStamp, sessionId, turnId, hostSupport } = @@ -64,21 +60,21 @@ export function useMobileStructuredAgentOptions(args: { () => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined), [sessionId] ) + // An options answer made before the account's listing landed listed none of it. const { projectPermission, optionState, optionStateRef, activeOptionRecordRef, - optionPicks, providerStarting, updateOptionState, conversationSupport, imageSupport, awaitingOptions, - writes, - readRef, receiveCatalog, - isCurrentContext + isCurrentContext, + controller, + reread } = useMobileStructuredOptionState({ agent, enabled, @@ -89,6 +85,7 @@ export function useMobileStructuredAgentOptions(args: { turnId, client, optionStamp, + sendSelection: hostSupport?.sendSelection === true, newLaunch: createdHere !== undefined, worktree: createdHere?.worktree, coherentCatalog: hostSupport?.coherentModelCatalog === true @@ -125,14 +122,19 @@ export function useMobileStructuredAgentOptions(args: { interactionReadyRef.current = !awaitingOptions lockSnapshotRef.current = lockSnapshot }) - const shown = lockSnapshot( - structuredAgentSessionShownSnapshot( + const readOptionState = useCallback( + () => projectPermission({ - ...optionState, + ...structuredAgentSessionOptionView( + optionStateRef.current, + undefined, + controller.overlay() + ), permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null - }) - ) + }), + [controller, projectPermission, hostSupport?.permissionOwn] ) + const shown = lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState())) const optionSnapshot = legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown @@ -143,128 +145,119 @@ export function useMobileStructuredAgentOptions(args: { ...optionStateRef.current, permissionOwnSupported: hostSupport ? hostSupport.permissionOwn === true : null }) + const view = structuredAgentSessionOptionView(currentState, undefined, controller.overlay()) + const pickState = { + ...view, + pendingId: + providerStarting || + id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || + !controller.isPending(id) + ? null + : id + } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && value === 'own' && !hostSupport?.permissionOwn) || !isCurrentContext() || + awaitingOptions || !client || !sessionId || !optionCatalog || encoded === null || !canSetStructuredAgentSessionOption( - { - ...structuredAgentSessionOptionView(currentState, undefined, {}), - pendingId: structuredOptionPickBlockingId(optionPicks.current, id, providerStarting) - }, + pickState, id, value, - lockSnapshotRef.current(structuredAgentSessionShownSnapshot(currentState)), + lockSnapshotRef.current( + structuredAgentSessionShownSnapshot( + structuredAgentSessionOptionView(currentState, undefined, controller.overlay()) + ) + ), interactionReadyRef.current ) ) { return false } const targetRecord = currentState.record - writes.current.count += 1 - const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - const pick = beginStructuredOptionPick(optionPicks.current, id, encoded, shownAtOnce) - const sameRecord = (): boolean => - isCurrentContext() && activeOptionRecordRef.current === targetRecord - const isCurrent = (): boolean => - sameRecord() && isLatestStructuredOptionPick(optionPicks.current, pick) - if (shownAtOnce) { - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOption(current, id, encoded) - : current - ) - } else { - updateOptionState((current) => ({ ...current, pendingId: id })) - } - let readsBack = shownAtOnce - try { + const model = structuredAgentSessionOptionPicks(view, { [id]: encoded })[0]?.modelId ?? null + const operation = controller.issue(id, encoded, model) + updateOptionState((current) => ({ + ...current, + pendingId: providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ? null : id + })) + void controller.flush(async (pick) => { + // A queued pick never retargets a replacement runtime. + if (activeOptionRecordRef.current !== targetRecord) { + return { kind: 'superseded' } + } const result = await mutate( 'agentSession.setOption', 'agentSession.setOption', - { key: id, value: encoded } + { + key: pick.key, + value: pick.value + } ) - // As on desktop, a reply that never came is not a pick done. - if (result.status !== 'accepted') { - return false - } - const committed = - sameRecord() && result.sameFence - ? structuredOptionPickCommit(optionPicks.current, pick, result.value.options) - : null - if (committed) { - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) - : current - ) - // Persist only accepted picks; uncertain optimism must not seed future launches with an option the provider may have refused. - if (agent) { - void persistMobileStructuredOptionPicks({ - client, - agent, - picks: structuredAgentSessionOptionPicks(currentState, committed) - }) - } - } - readsBack = result.sameFence - return true - } finally { - if (sameRecord()) { - settleStructuredOptionPick(optionPicks.current, pick) - } - if (isCurrent()) { - updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } - : current - ) - } - if (sameRecord()) { - writes.current.count -= 1 - writes.current.readback ||= readsBack && isCurrent() - if (!writes.current.count && writes.current.readback) { - readRef.current?.() - } + return result.status === 'accepted' + ? { kind: 'accepted', options: result.value.options ?? { [pick.key]: pick.value } } + : result.status === 'unknown' + ? { kind: 'unknown' } + : { kind: 'refused' } + }) + const outcome = await operation.result + if (activeOptionRecordRef.current === targetRecord) { + updateOptionState((current) => ({ + ...current, + pendingId: + current.pendingId !== null && controller.isPending(current.pendingId) + ? current.pendingId + : null + })) + if (outcome.kind === 'refused') { + reread() } } + return outcome.kind !== 'refused' }, [ - agent, - awaitingOptions, hostSupport?.permissionOwn, projectPermission, + providerStarting, client, - createdHere, + awaitingOptions, + controller, mutate, optionCatalog, - optionPicks, - providerStarting, - writes, - isCurrentContext, + reread, sessionId, - updateOptionState + updateOptionState, + isCurrentContext ] ) - const sendSelection = useCallback( - () => structuredAgentSessionSendSelection(optionStateRef.current), - [] - ) + // Controls and capture derive from the same queried base and transient operations. + const sendSelection = useCallback(() => { + const view = structuredAgentSessionOptionView( + optionStateRef.current, + undefined, + controller.overlay() + ) + return hostSupport?.sendSelection + ? { selection: structuredAgentSessionMessageSelection(view) } + : hostSupport?.sendOptions + ? { options: structuredAgentSessionSendSelection(view) } + : {} + }, [controller, hostSupport?.sendSelection, hostSupport?.sendOptions]) + const { optionPickerRequest, invokeStructuredOption, optionSurface } = useMobileStructuredOptionSurface({ optionSnapshot, demandModelCatalog, + lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot), setStructuredOption, - optionStateRef, - projectPermission, - lockSnapshot: (snapshot) => lockSnapshotRef.current(snapshot) + readOptionState }) return { diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 5f2dc43524e..a7a5bad603c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -42,6 +42,21 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +function normalReply(method: string): RpcResponse { + if (method === 'agentSession.options') { + return ok({ models: [], current: {} }) + } + if (method === 'agentSession.modelCatalog') { + return { + id: 'catalog', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return ok({}) +} + describe('mobile structured queued messages', () => { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null @@ -92,17 +107,17 @@ describe('mobile structured queued messages', () => { event: AgentSessionSubscribeEvent = snapshotEvent(), sessionId?: string ): Promise { - act(() => { + await act(async () => { renderer = create( createElement(Harness, { hostSupport, ...(sessionId ? { sessionId } : {}) }) ) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) - act(() => listener?.(event)) + await act(async () => listener?.(event)) } - function unmountSession(): void { - act(() => renderer?.unmount()) + async function unmountSession(): Promise { + await act(async () => renderer?.unmount()) renderer = null hook = null listener = null @@ -134,14 +149,12 @@ describe('mobile structured queued messages', () => { asyncStorage.removeItem.mockImplementation(async (key: string) => { stored.delete(key) }) - sendRequest.mockImplementation(async (method) => - method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - ) + sendRequest.mockImplementation(async (method) => normalReply(method)) }) - afterEach(() => { + afterEach(async () => { vi.useRealTimers() - unmountSession() + await unmountSession() }) it('reverts an optimistic relative move when the host reports a changed queue', async () => { @@ -158,17 +171,19 @@ describe('mobile structured queued messages', () => { await mountSession(CAPABLE, snapshotEvent({ queuedMessages: messages })) expect(hook!.queued.move).toBeUndefined() expect(calls('agentSession.queuedMessageMove')).toHaveLength(0) - unmountSession() + await unmountSession() await mountSession( { ...CAPABLE, queuedMessageReorder: true }, snapshotEvent({ queuedMessages: messages }) ) let finish: (response: RpcResponse) => void = () => {} - sendRequest.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) + const otherMethods = sendRequest.getMockImplementation()! + sendRequest.mockImplementation((method, params) => + method === 'agentSession.queuedMessageMove' + ? new Promise((resolve) => { + finish = resolve + }) + : otherMethods(method, params) ) let moving!: Promise await act(async () => { @@ -216,7 +231,7 @@ describe('mobile structured queued messages', () => { queued: { messageId: 'client-1', position: 1, state: 'waiting' } }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession(CAPABLE) await act(async () => { @@ -245,7 +260,7 @@ describe('mobile structured queued messages', () => { submission: acceptedSubmission('client-1') }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession(CAPABLE) await vi.waitFor(() => @@ -275,7 +290,7 @@ describe('mobile structured queued messages', () => { } }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession(LEGACY) await act(async () => { @@ -296,7 +311,7 @@ describe('mobile structured queued messages', () => { let attempts = 0 sendRequest.mockImplementation(async (method, params) => { if (method !== 'agentSession.send') { - return ok({ models: [], current: {} }) + return normalReply(method) } if (++attempts === 1) { throw markRpcDeliveryUnknown(new Error('Connection closed')) @@ -308,7 +323,7 @@ describe('mobile structured queued messages', () => { await act(async () => { expect(await hook!.sendWithOutcome('again')).toBe('unknown') }) - unmountSession() + await unmountSession() await mountSession(LEGACY) await act(async () => { expect(await hook!.sendWithOutcome('again')).toBe('accepted') @@ -325,7 +340,7 @@ describe('mobile structured queued messages', () => { let attempts = 0 sendRequest.mockImplementation(async (method, params) => { if (method !== 'agentSession.send') { - return ok({ models: [], current: {} }) + return normalReply(method) } if (++attempts === 1) { throw markRpcDeliveryUnknown(new Error('Connection closed')) @@ -467,7 +482,7 @@ describe('mobile structured queued messages', () => { } }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession(CAPABLE) await act(async () => { @@ -481,9 +496,7 @@ describe('mobile structured queued messages', () => { sendRequest.mockImplementation(async (method) => method === 'agentSession.queuedMessageSend' || method === 'agentSession.queuedMessageDelete' ? held.promise - : method === 'agentSession.options' - ? ok({ models: [], current: {} }) - : ok({}) + : normalReply(method) ) await mountSession(CAPABLE) const presses: Promise[] = [] @@ -507,7 +520,7 @@ describe('mobile structured queued messages', () => { if (method === 'agentSession.queuedMessageDelete') { return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession(CAPABLE) await act(async () => { @@ -523,9 +536,7 @@ describe('mobile structured queued messages', () => { ? fieldsOf(params).messageId === 'draft-1' ? mutationOk({ deleted: true, messageId: 'draft-1' }) : mutationOk({ deleted: false, messageId: 'draft-2', disposition: 'missing' }) - : method === 'agentSession.options' - ? ok({ models: [], current: {} }) - : ok({}) + : normalReply(method) ) await mountSession( LEGACY, @@ -542,7 +553,7 @@ describe('mobile structured queued messages', () => { if (method === 'agentSession.queuedMessageDelete') { return mutationOk({ deleted: true, messageId: 'draft-1' }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession( LEGACY, @@ -565,7 +576,7 @@ describe('mobile structured queued messages', () => { if (method === 'agentSession.queuedMessageDelete') { return { id: 'request-1', ok: false, error: { code: 'runtime_error', message: 'boom' } } } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession( LEGACY, @@ -584,7 +595,7 @@ describe('mobile structured queued messages', () => { if (method === 'agentSession.queuedMessageDelete') { return mutationOk({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession( LEGACY, @@ -599,9 +610,7 @@ describe('mobile structured queued messages', () => { }) it('Edit that copied nothing deletes nothing and does not report a copy', async () => { - sendRequest.mockImplementation(async (method) => - method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - ) + sendRequest.mockImplementation(async (method) => normalReply(method)) await mountSession( LEGACY, snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) @@ -624,7 +633,7 @@ describe('mobile structured queued messages', () => { if (method === 'agentSession.cancel') { return mutationOk({ cancelled: true, turnId: 'turn-1' }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession( CAPABLE, @@ -681,7 +690,7 @@ describe('mobile structured queued messages', () => { }) : mutationOk({ resumed: true }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession( CAPABLE, @@ -705,7 +714,7 @@ describe('mobile structured queued messages', () => { if (method === 'agentSession.cancel') { return mutationOk({ cancelled: true, turnId: 'turn-1' }) } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + return normalReply(method) }) await mountSession(LEGACY, snapshotEvent({ runningTurn: true })) await act(async () => { diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 69d2763adac..2d5d7f2a47a 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -644,7 +644,7 @@ describe('useMobileStructuredAgentSession', () => { expect(retryId).not.toBe(firstId) }) - it('does not commit a structured option change after unknown delivery, as desktop does', async () => { + it('keeps unknown intent until a current query, without accepted memory or resend', async () => { act(() => { renderer = create(createElement(Harness)) }) @@ -656,20 +656,38 @@ describe('useMobileStructuredAgentSession', () => { settable: true }) }) + let reconcile!: (response: Awaited>) => void sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.setOption') { throw markRpcDeliveryUnknown(new Error('Connection closed')) } + if (method === 'agentSession.options') { + return new Promise>>((resolve) => { + reconcile = resolve + }) + } return defaultSendRequest(method, params) }) onSendError.mockClear() await act(async () => { - expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(false) + expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true) }) const model = hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model') - expect(model?.kind).not.toMatchObject({ currentValue: 'gpt-slow' }) + expect(model?.kind).toMatchObject({ currentValue: 'gpt-slow' }) + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.setOption') + ).toHaveLength(1) + expect( + sendRequest.mock.calls.filter( + ([method]) => method === 'settings.mutateNativeChatSessionOptions' + ) + ).toHaveLength(0) + await act(async () => reconcile(await defaultSendRequest('agentSession.options'))) + expect(hook!.optionSnapshot.find((row) => row.id === 'model')?.kind).toMatchObject({ + currentValue: 'gpt-fast' + }) expect(onSendError).not.toHaveBeenCalled() }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index efb030ec149..9ea1b5042e4 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -203,7 +203,7 @@ export function useMobileStructuredAgentSession( sessionCommands: args.sessionCommands, onSendError, queueSendsNext, - sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined, + sendSelection: options.sendSelection, takesSkills: hostSupport?.sendSkills === true }) const { items, currentRequests, actionablePromptIds, status } = state diff --git a/mobile/src/session/use-mobile-structured-option-state.ts b/mobile/src/session/use-mobile-structured-option-state.ts index a9217c1bb56..8e5b580b809 100644 --- a/mobile/src/session/use-mobile-structured-option-state.ts +++ b/mobile/src/session/use-mobile-structured-option-state.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from ' 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 @@ -13,10 +14,6 @@ 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 { - createStructuredOptionPickBook, - resetStructuredOptionPickBook -} from '../../../src/shared/structured-agent-session-option-pick-book' import { createStructuredAgentSessionOptionState, applyStructuredAgentSessionOptions, @@ -28,6 +25,7 @@ import { export function useMobileStructuredOptionState(args: { client: RpcClient | null optionStamp?: string + sendSelection?: boolean newLaunch: boolean worktree?: string coherentCatalog?: boolean @@ -49,6 +47,7 @@ export function useMobileStructuredOptionState(args: { turnId, client, optionStamp, + sendSelection, newLaunch, worktree, coherentCatalog: hostCoherentCatalog = false @@ -64,7 +63,6 @@ export function useMobileStructuredOptionState(args: { const summary = useSyncExternalStore(feed?.subscribe ?? (() => () => {}), () => sessionId ? feed?.getSnapshot().get(sessionId) : undefined ) - // As on desktop, a host still starting the provider holds option writes; picks show at once. const providerStarting = summary?.hostExecutionPhase === 'starting' const projectPermission = (state: T): T => projectRuntimePermission(state, summary, fence) @@ -74,7 +72,18 @@ export function useMobileStructuredOptionState(args: { ) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - const optionPicks = useRef(createStructuredOptionPickBook()) + const readEpoch = useRef(0) + const readRef = useRef<(() => void) | null>(null) + const reread = useCallback(() => { + readEpoch.current += 1 + readRef.current?.() + }, []) + const { controller, clientGeneration } = useMobileStructuredSelectionController({ + agent, + client, + sessionId, + onRevalidate: reread + }) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { const next = update(optionStateRef.current) @@ -92,20 +101,24 @@ export function useMobileStructuredOptionState(args: { () => contextRef.current.key === contextKey && contextRef.current.client === client, [contextKey, client] ) - const writes = useRef({ count: 0, readback: false }) - const optionIdentityRef = useRef(`${agent}:${sessionId}`) + const optionIdentityRef = useRef({ agent, sessionId, client, controller }) useEffect(() => { - const identity = `${agent}:${sessionId}` - const sameSession = optionIdentityRef.current === identity - optionIdentityRef.current = identity + 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.catalogSource !== 'seed' && previous.catalogSource !== null + sameSession && + (previous.optionsAnswerPresent || + (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) ? { ...previous, - catalogSource: 'host' as const, record: cloneStructuredAgentSessionOptionRecord(previous.record), pendingId: null, reportedPermissionMode: undefined @@ -113,12 +126,21 @@ export function useMobileStructuredOptionState(args: { : seeded // Keep the chat’s mode visible through provider restarts. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept - writes.current = { count: 0, readback: false } - resetStructuredOptionPickBook(optionPicks.current) + readEpoch.current += 1 optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) - }, [agent, enabled, fence, optionCatalog, sessionId, contextKey, client]) + }, [ + agent, + enabled, + fence, + optionCatalog, + sessionId, + contextKey, + client, + clientGeneration, + controller + ]) const [conversationSupport, setConversationSupport] = useState<{ sessionId: string @@ -134,7 +156,6 @@ export function useMobileStructuredOptionState(args: { const awaitingOptions = conversationSupport?.sessionId === sessionId && conversationSupport.fence !== fence - const readRef = useRef<(() => void) | null>(null) const receiveCatalog = ( catalog: AgentSessionModelCatalogResult | null, _event: 'demand' | 'progress' | 'settled' @@ -153,7 +174,13 @@ export function useMobileStructuredOptionState(args: { } useEffect(() => { // Read each turn and after provider start; before start only a resumed chat has model evidence. - if (!client || !sessionId || !enabled || !optionCatalog || (providerStarting && newLaunch)) { + if ( + !client || + !sessionId || + !enabled || + !optionCatalog || + (providerStarting && newLaunch && !sendSelection) + ) { return } let stale = false @@ -163,13 +190,10 @@ export function useMobileStructuredOptionState(args: { if (stale || !isCurrentContext()) { return } - if (writes.current.count) { - writes.current.readback = true - return - } - writes.current.readback = false const record = activeOptionRecordRef.current - const readGeneration = optionPicks.current.generation + const epoch = readEpoch.current + const generation = client.getGeneration?.() + const completed = controller.captureRead() const result = await callAgentSession( client, 'agentSession.options', @@ -185,7 +209,7 @@ export function useMobileStructuredOptionState(args: { acceptsImages: result.acceptsImages, statesPermissionMode: result.current?.permissionMode !== undefined }) - if (optionPicks.current.generation !== readGeneration) { + if (readEpoch.current !== epoch || client.getGeneration?.() !== generation) { return } setConversationSupport({ sessionId, fence, commands: result.conversationCommands ?? [] }) @@ -195,6 +219,7 @@ export function useMobileStructuredOptionState(args: { coherentCatalog }) ) + controller.retire(completed) }).catch(() => undefined) } readRef.current = request @@ -205,10 +230,13 @@ export function useMobileStructuredOptionState(args: { } }, [ client, + clientGeneration, newLaunch, enabled, optionCatalog, optionStamp, + sendSelection, + controller, isCurrentContext, coherentCatalog, providerStarting, @@ -223,14 +251,13 @@ export function useMobileStructuredOptionState(args: { conversationSupport, imageSupport, awaitingOptions, - writes, - readRef, + controller, + reread, receiveCatalog, isCurrentContext, optionState, optionStateRef, activeOptionRecordRef, - optionPicks, providerStarting, updateOptionState } diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts index 980f2e55896..318e1aadd18 100644 --- a/mobile/src/session/use-mobile-structured-option-surface.ts +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type RefObject } from 'react' +import { useCallback, useMemo, useState } from 'react' import { structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' import type { StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' import type { @@ -12,10 +12,10 @@ export function useMobileStructuredOptionSurface(args: { lockSnapshot: (snapshot: SessionOptionDescriptor[]) => SessionOptionDescriptor[] optionSnapshot: SessionOptionDescriptor[] setStructuredOption: (id: string, value: SessionOptionValue) => Promise - optionStateRef: RefObject - projectPermission: (state: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState + /** The latest option state, which a settled pick's snapshot is read from. */ + readOptionState: () => StructuredAgentSessionOptionState }) { - const { optionSnapshot, optionStateRef, setStructuredOption, demandModelCatalog } = args + const { optionSnapshot, readOptionState, setStructuredOption, demandModelCatalog } = args const [optionPickerRequest, setOptionPickerRequest] = useState<{ id: string sequence: number @@ -35,13 +35,9 @@ export function useMobileStructuredOptionSurface(args: { const setOption = useCallback( async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) - return { - snapshot: args.lockSnapshot( - structuredAgentSessionShownSnapshot(args.projectPermission(optionStateRef.current)) - ) - } + return { snapshot: args.lockSnapshot(structuredAgentSessionShownSnapshot(readOptionState())) } }, - [args.lockSnapshot, optionStateRef, setStructuredOption] + [args.lockSnapshot, readOptionState, setStructuredOption] ) const optionSurface = useMemo( diff --git a/mobile/src/session/use-mobile-structured-selection-controller.ts b/mobile/src/session/use-mobile-structured-selection-controller.ts new file mode 100644 index 00000000000..6f1a0b548c9 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-selection-controller.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { createStructuredSessionSelectionController } from '../../../src/shared/structured-agent-session-selection-operations' +import { persistMobileStructuredOptionPicks } from './native-chat/input/mobile-native-chat-session-option-persistence' +import type { RpcClient } from '../transport/rpc-client' + +/** Temporary operations belong to one phone authority, above its queried host answer. */ +export function useMobileStructuredSelectionController(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + onRevalidate: () => void +}) { + const { agent, client, sessionId, onRevalidate } = args + const clientGeneration = useSyncExternalStore(client?.onStateChange ?? (() => () => {}), () => + client?.getGeneration?.() + ) + const scope = useMemo(() => { + let attached = true + const controller = createStructuredSessionSelectionController( + (picks) => { + if (agent) { + void persistMobileStructuredOptionPicks({ client, agent, picks }) + } + }, + () => attached + ) + return { + controller, + attach: () => { + attached = true + }, + detach: () => { + attached = false + controller.abandon() + } + } + }, [agent, client, sessionId]) + const { controller } = scope + useEffect(() => { + scope.attach() + return scope.detach + }, [scope]) + const selectionRevision = useSyncExternalStore(controller.subscribe, controller.snapshot) + const terminal = controller.terminalSnapshot() + useEffect(() => { + if (terminal) { + onRevalidate() + } + }, [terminal, onRevalidate]) + useEffect(() => { + return client?.onStateChange((state) => { + if (state === 'connected') { + onRevalidate() + } + }) + }, [client, controller, onRevalidate]) + return { controller, selectionRevision, clientGeneration } +} diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 77a1ff5a0e7..0de5ab7453f 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -9,7 +9,8 @@ import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-ses import { SkillRefLimitError, structuredAgentSessionSendBody, - type StructuredAgentSessionAttachment + type StructuredAgentSessionAttachment, + type StructuredAgentSessionSendControl } from '../../../src/shared/structured-agent-session-send-mutation' import type { AgentJournalSkillRef } from '../../../src/shared/agent-session-journal-types' import { MAX_SKILL_REFS } from '../../../src/shared/rpc-contract/structured-agent-session-message-params' @@ -75,6 +76,8 @@ export function useMobileStructuredSendWithOutcome(args: { sessionCommands?: readonly AgentSessionSlashCommand[] onSendError: MobileNativeChatSendErrorReporter // Older hosts accept neither per-chat model nor effort. + /** The chat's own model and effort each send carries; absent for an older host, which takes neither. */ + sendSelection?: () => Pick sendOptions?: () => Readonly> | undefined // An older host refuses a send body carrying `skills`; there the `$name` text stands alone. takesSkills?: boolean @@ -101,6 +104,7 @@ export function useMobileStructuredSendWithOutcome(args: { sendOptions, sendsOut, sessionCommands, + sendSelection, sessionId, stateRef, stopping, @@ -128,6 +132,7 @@ export function useMobileStructuredSendWithOutcome(args: { onSendError('Message not sent') return 'rejected' } + const captured = sendSelection?.() ?? { options: sendOptions?.() } const sendAttachments = attachments ?? [] const commandOutcome = await dispatchMobileStructuredCommand({ text, @@ -195,8 +200,6 @@ export function useMobileStructuredSendWithOutcome(args: { queuedAt: Date.now(), ...(stopping ? { sentWhileStopping: true as const } : {}) } - // Wait for pending picks so the send carries this chat’s settled options. - const options = sendOptions?.() const outcome = await sendsOut.trackSend(sendMobileStructuredAgentSessionMessage)({ client, sessionId, @@ -207,7 +210,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, queueFollowUps, stateRef.current) ? { delivery: 'queue-if-active' as const } : {}), - ...(options ? { options } : {}), + ...captured, deadline, clientMessageId: send.clientMessageId, onAccepted: (cursor) => @@ -235,6 +238,7 @@ export function useMobileStructuredSendWithOutcome(args: { sendOptions, sendsOut, sessionCommands, + sendSelection, sessionId, stateRef, stopping, diff --git a/mobile/src/session/use-mobile-structured-session-option-stamp.ts b/mobile/src/session/use-mobile-structured-session-option-stamp.ts index 7035eccfd4a..6dcfc2f4dae 100644 --- a/mobile/src/session/use-mobile-structured-session-option-stamp.ts +++ b/mobile/src/session/use-mobile-structured-session-option-stamp.ts @@ -1,7 +1,7 @@ import { useCallback, useSyncExternalStore } from 'react' import type { RpcClient } from '../transport/rpc-client' -import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' import { structuredAgentSessionOptionStamp } from '../../../src/shared/structured-agent-session-option-state' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' const noSubscription = (): (() => void) => () => {} @@ -17,9 +17,9 @@ export function useMobileStructuredSessionOptionStamp(args: { (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), [feed] ) - const readStamp = (): string => - structuredAgentSessionOptionStamp( - feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined - ) + const readStamp = (): string => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + return JSON.stringify([structuredAgentSessionOptionStamp(summary), summary?.hostExecutionPhase]) + } return useSyncExternalStore(subscribe, readStamp, readStamp) } diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 806c45952e4..d04af64caec 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -473,7 +473,13 @@ describe('Claude effort default at rest', () => { const result = await readAtRest(store, record) - expect(result.current).toEqual({ model: 'gpt-5.5', defaulted: [], permissionMode: 'ask' }) + expect(result.current).toEqual({ + model: 'gpt-5.5', + defaulted: [], + permissionMode: 'ask', + acceptedInputSources: { model: 'saved' } + }) + expect(result.current?.effort).toBeUndefined() }) it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index ac747a37717..f9ac5f93337 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,3 +1,11 @@ +import { CODEX_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState +} from '../../shared/structured-agent-session-options' +import { structuredAgentSessionMessageSelection } from '../../shared/structured-agent-session-option-view' +import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { describe, expect, it, vi } from 'vitest' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { optionSession } from './codex-structured-option-session.test-fixture' @@ -329,3 +337,80 @@ describe('Codex captured message choices', () => { }) }) }) + +it.each([true, false])( + 'sends delayed client literal with accepted effort=%s after another client changes the host tuple', + async (acceptedEffort) => { + const literal = 'retired-literal' + const model = { + id: 'model-b', + label: 'Model B', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + const clientA = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [model], + current: { + model: literal, + effort: 'high', + confirmed: ['model', 'effort'], + defaulted: [], + acceptedInputSources: { + model: 'saved', + ...(acceptedEffort ? { effort: 'saved' as const } : {}) + } + } + } + ) + const request = vi.fn(async (method: string, _params?: Record) => + method === 'turn/start' ? { turn: { id: 'literal-turn' } } : { config: {} } + ) + const session = optionSession(request) + seedMessageDefaults(session) + session.options = new Map([ + ['model', model.id], + ['effort', 'high'] + ]) + session.reportedOptions = { model: model.id, effort: 'high' } + session.appliedTurnOptions = { model: model.id, effort: 'high' } + const expected = { model: literal, ...(acceptedEffort ? { effort: 'high' } : {}) } + const captured = captureStructuredExecutionOptions( + { + agents: claudeAndCodexAgents(), + agent: 'codex', + persistedOptions: { model: model.id, effort: 'high' } + }, + undefined, + structuredAgentSessionMessageSelection(clientA) + ) + if ('refusal' in captured) { + throw new Error('accepted snapshot was refused') + } + expect(captured.options).toEqual(expected) + const turnOptions = await resolvedCodexMessageOptions(session, captured.options) + expect(turnOptions).toEqual(expected) + expect(Object.fromEntries(session.options)).toEqual({ model: model.id, effort: 'high' }) + await startCodexTurn(session, { + clientMessageId: 'delayed-client', + body: USER_MESSAGE, + turnOptions + }) + const turn = request.mock.calls.filter(([method]) => method === 'turn/start') + expect(turn).toHaveLength(1) + expect(turn[0]?.[1]).toMatchObject({ + threadId: 'thread-1', + clientUserMessageId: 'delayed-client', + ...expected + }) + if (!acceptedEffort) { + expect(turn[0]?.[1]).not.toHaveProperty('effort') + } + } +) diff --git a/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts index 70bed45c712..71c8da501e2 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts @@ -120,6 +120,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { afterEach(() => { vi.restoreAllMocks() resetRuntimeEnvironmentStatusOwners() + vi.unstubAllGlobals() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts index a9b7044fae4..022963b94a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-applied-inputs.test.ts @@ -160,6 +160,11 @@ it('steers inherited applied inputs and sends an explicit effort or tier change await eventually(() => expect(calls('turn/steer')).toHaveLength(2)) const tier = await send({ model: 'model-a', serviceTier: 'flex' }) await expectHeld(tier, 3, 2) + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === tier + ) + ).toMatchObject({ options: { serviceTier: 'flex' } }) codex.routes['turn/start'] = openTurn('turn-flex') await finish('turn-high') await eventually(() => expect(calls('turn/start')).toHaveLength(4)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 9af72af53bf..1b49a479f33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -1,8 +1,8 @@ import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { isAgentSessionCreateAdmissionRefusal } from '../../../shared/agent-session-definitive-refusal' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { refuse, type AgentSessionAttachResult, @@ -31,10 +31,10 @@ import { observedReceipt } from './structured-agent-session-operation-settlement import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' -import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { openingMessageOptions } from './structured-agent-session-execution-options' export type StructuredAgentSessionCreateOptions = { - firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } + firstMessage?: StructuredAgentSessionFirstMessage hostLaunchDirectory?: string catalogModels?: Parameters[0]['models'] /** Publish after durable creation, before agent delivery starts. */ @@ -212,14 +212,12 @@ async function foundConversation( }, { ...first, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body: first.body } - }), + payloadFingerprint: agentSessionSendBodyFingerprint(sessionId, first.body), origin: 'client', source: { kind: 'user' }, - ...openingMessageOptions(candidate.record.options) + ...(first.selection === undefined + ? openingMessageOptions(candidate.record.options) + : {}) } ) if (!sent.ok) { @@ -297,11 +295,3 @@ export function structuredAgentSessionEntryDelegates( ) => createStructuredAgentSession(context(), caller.callerKey, params, options) } } - -/** Opening messages retain creation-time model/effort choices like later sends. */ -function openingMessageOptions(options: Readonly> | undefined): { - options?: AgentSessionExecutionOptions -} { - const { model, effort } = options ?? {} - return model ? { options: { model, ...(effort ? { effort } : {}) } } : {} -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts index fae7bf70345..bd604ee769a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -9,16 +9,23 @@ import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent /** Capture send-specific choices or saved defaults once under the acceptance lane. */ export function captureStructuredExecutionOptions( ctx: Pick, - supplied?: Readonly> + supplied?: Readonly>, + selection?: Readonly> ): { options: AgentSessionExecutionOptions } | { refusal: AgentSessionWireRefusal } { const saved = ctx.persistedOptions ?? {} - const candidate = { - ...(saved.model ? { model: saved.model } : {}), - ...(saved.effort ? { effort: saved.effort } : {}), - ...(saved.personality ? { personality: saved.personality } : {}), - ...(saved.fastMode ? { fastMode: saved.fastMode } : {}), - ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}), - ...supplied + const candidate: Record = + selection === undefined + ? { ...saved, ...supplied } + : { + ...(saved.personality ? { personality: saved.personality } : {}), + ...selection + } + // A missing Fast pick has a definite baseline on hosts that offer it. + if ( + candidate.fastMode === undefined && + ctx.agents.definition(ctx.agent)?.restingOptions.acceptsKey('fastMode') + ) { + candidate.fastMode = 'false' } const options = readAgentSessionExecutionOptions(candidate) if (!options) { @@ -39,3 +46,12 @@ export function captureStructuredExecutionOptions( ) return refusal ? { refusal } : { options } } + +/** The model and effort the chat was created with, which its opening message runs on as a send's + * carried ones do. */ +export function openingMessageOptions(options: Readonly> | undefined): { + options?: AgentSessionExecutionOptions +} { + const { model, effort } = options ?? {} + return model ? { options: { model, ...(effort ? { effort } : {}) } } : {} +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 5e119580bbb..95479eab140 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -67,6 +67,7 @@ export function sendStructuredAgentSessionTurn( delivery?: 'queue-if-active' queuedImageCards?: boolean options?: Readonly> + selection?: Readonly> usageLimitContinuationOfTurnItemId?: string /** Client send RPCs set origin=client for recovery; host mail/continuations/launch sends omit it. */ userSend?: true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index f17351cd50c..9d548fa1ed3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -290,7 +290,8 @@ it('carries Claude effort validation through the SDK queue and saved desktop/pho expect(nativeWrite.mock.calls.map(([flags]) => flags)).toEqual([ { effortLevel: 'medium' }, { effortLevel: 'low' }, - { effortLevel: 'high' } + { effortLevel: 'high' }, + { fastMode: false } ]) expect(prompts).toHaveLength(1) expect(prompts[0]?.message.content).toEqual([{ type: 'text', text: 'valid effort' }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 73b06152ebd..9c9b2dc2206 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -152,6 +152,8 @@ export function sendPlan( ...(source ? { source } : {}), clientMessageId, ...(params.options ? { options: params.options } : {}), + ...(params.selection !== undefined ? { selection: params.selection } : {}), + // Body-only, so the reducer's echo aliasing never sees control fields or the sender. payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.accepted-inputs.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.accepted-inputs.test.ts new file mode 100644 index 00000000000..d9cd7903c17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.accepted-inputs.test.ts @@ -0,0 +1,239 @@ +import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex' +import { + structuredAgentSessionMessageSelection, + structuredAgentSessionOptionView +} from '../../../shared/structured-agent-session-option-view' +import { claudeOpened } from '../../claude/claude-structured-startup-settled-test-support' + +import type { ClaudeStructuredSessionEvent } from '../../claude/structured/claude-structured-session-state' + +import { + acquired, + fakeClaude, + claudeFrame, + USER_MESSAGE +} from '../../claude/claude-structured-session-test-support' + +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState +} from '../../../shared/structured-agent-session-options' + +import { expect, it, vi } from 'vitest' +import { + hostTestRestingOptionsRecord as restingRecord, + HOST_TEST_RESTING_OPTIONS_CREATED_LINKS as created, + hostTestRestingOptionsJournal as restingJournal, + hostTestOptionsReadStore +} from './structured-agent-session-host-test-data' + +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' + +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +import { + claudeAndCodexAgents, + NO_STRUCTURED_AGENTS +} from './structured-agent-session-adapter-router-test-support' + +const SESSION = 'session-1' + +it('projects held Claude reports through the full reader while actual controls and Send proceed', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + const native = fakeClaude() + const adapter = await acquired(native, {}, events) + await claudeOpened(adapter, SESSION) + const connection = native.connections[0]! + const pending = Promise.withResolvers() + const settings = vi.spyOn(connection, 'getSettings').mockReturnValueOnce(pending.promise) + const queue = new StructuredAgentSessionTaskQueue() + const record = { ...restingRecord(), provider: 'claude', options: {} } + const live = { child: { fence: 7 }, params: { provider: 'claude' }, journal: restingJournal() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the full reader uses only these context members; provider operations use the actual adapter. + const context = { + deps: { + adapter, + agents: claudeAndCodexAgents(), + store: hostTestOptionsReadStore(() => record) + }, + serialize: (id: string, task: () => Promise) => queue.serialize(id, task), + openConversation: async () => live, + conversation: async () => live + } as unknown as StructuredAgentSessionMutationContext + const read = () => + readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + try { + claudeFrame(connection, { type: 'result', subtype: 'success', result: 'done', is_error: false }) + await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce()) + expect((await read()).current.effort).toBe('high') + await queue.serialize(SESSION, () => + adapter.dispatch({ + sessionId: SESSION, + fence: 7, + clientMessageId: 'while-reporting', + body: USER_MESSAGE + }) + ) + expect(connection.sent.length).toBeGreaterThan(0) + await queue.serialize(SESSION, () => + adapter.setOption({ sessionId: SESSION, fence: 7, key: 'model', value: 'claude-opus-5' }) + ) + pending.resolve({ + applied: { model: 'old', effort: 'max' }, + effective: { effortLevel: 'max', fastMode: true } + }) + await Promise.resolve() + await Promise.resolve() + expect((await read()).current).toMatchObject({ model: 'claude-opus-5', effort: 'high' }) + settings.mockResolvedValueOnce({ + applied: { model: 'claude-opus-5', effort: 'low' }, + effective: { effortLevel: 'low', fastMode: true } + }) + claudeFrame(connection, { + type: 'result', + subtype: 'success', + result: 'later', + is_error: false + }) + await vi.waitFor(async () => + expect((await read()).current).toMatchObject({ effort: 'low', fastMode: false }) + ) + expect( + events.some( + (event) => event.type === 'options-reported' && event.reportedOptions.effort === 'max' + ) + ).toBe(false) + } finally { + pending.resolve(null) + await adapter.closeSession(SESSION) + } +}) + +it('uses Off for unpicked Claude Fast at rest and over a native On report, preserving explicit true and false', async () => { + const record = { + ...restingRecord(), + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/homes/a' } + } + const live = vi.fn(async () => ({ models: [], current: { model: 'opus', fastMode: true } })) + const session = { + params: { provider: 'claude' }, + child: { phase: 'starting', fence: 1 }, + journal: restingJournal() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies all fields the effective options reader uses. + const context = { + deps: { + adapter: { readOptions: live }, + agents: claudeAndCodexAgents(), + store: hostTestOptionsReadStore(() => record) + }, + serialize: (_id: string, task: () => Promise) => task(), + openConversation: async () => session, + conversation: async () => session + } as unknown as StructuredAgentSessionMutationContext + const starting = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + expect(starting.current.fastMode).toBe(false) + expect(live).not.toHaveBeenCalled() + expect(starting.current.acceptedInputSources).toEqual({ fastMode: 'policy' }) + session.child.phase = 'ready' + expect( + (await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })).current + .fastMode + ).toBe(false) + for (const fastMode of ['true', 'false']) { + record.options = { fastMode } + const answer = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + expect(answer.current.acceptedInputSources).toEqual({ fastMode: 'saved' }) + expect(answer.current.fastMode).toBe(fastMode === 'true') + } +}) + +it.each([false, true])( + 'captures accepted origins through host read and shared view, resumed=%s', + async (resumed) => { + const record = restingRecord() + if (resumed) { + record.providerHandleChain = [{ ...created[0]!, origin: 'adopted' }] + } + const models: AgentSessionModelOption[] = [ + { + id: 'opus[1m]', + label: 'Alias', + isDefault: false, + efforts: [{ value: 'high', label: 'High' }], + serviceTiers: [{ value: 'priority', label: 'Fast' }] + }, + { + id: 'other', + label: 'Other', + isDefault: false, + efforts: [], + serviceTiers: [{ value: 'flex', label: 'Flex' }] + } + ] + const session = { + child: { fence: 1 }, + params: { provider: 'codex' }, + journal: restingJournal() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the fields used by the options read. + const context = { + deps: { + adapter: { + readOptions: async () => ({ + models, + current: { model: 'opus[1m]', effort: 'high', confirmed: ['model', 'effort'] } + }) + }, + modelCatalog: { read: async () => ({ origin: 'probe', fetchedAt: 1, models }) }, + agents: NO_STRUCTURED_AGENTS, + store: hostTestOptionsReadStore(() => record) + }, + serialize: (_id: string, task: () => Promise) => task(), + openConversation: async () => session, + conversation: async () => session + } as unknown as StructuredAgentSessionMutationContext + const capture = async () => { + const answer = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + answer, + { newLaunch: !resumed } + ) + return { + answer, + selection: structuredAgentSessionMessageSelection( + structuredAgentSessionOptionView(state, undefined, {}) + ) + } + } + const initial = await capture() + expect(initial.answer.current.acceptedInputSources).toEqual({}) + expect(initial.selection).toEqual({}) + for (const model of ['opus[1m]', 'retired-unlisted']) { + record.options = { model, effort: 'high', serviceTier: 'flex' } + const captured = await capture() + expect(captured.answer.current.acceptedInputSources).toEqual({ + model: 'saved', + effort: 'saved', + serviceTier: 'saved' + }) + expect(captured.selection).toEqual({ + model, + effort: 'high', + serviceTier: model === 'opus[1m]' ? 'default' : 'flex' + }) + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 1a0135216bc..388b11566ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -1,6 +1,9 @@ -import { claudeOpened } from '../../claude/claude-structured-startup-settled-test-support' +import { CODEX_SESSION_OPTION_CATALOG } from '../../../shared/agent-session-option-catalog-claude-codex' +import { + structuredAgentSessionMessageSelection, + structuredAgentSessionSendSelection +} from '../../../shared/structured-agent-session-option-view' import { agentSessionRecordFixture } from '../agent-session-record-test-fixture' -import type { ClaudeStructuredSessionEvent } from '../../claude/structured/claude-structured-session-state' import { startCodexTurn } from '../../codex/codex-structured-turn-start' import { readLiveCodexSessionOptions } from '../../codex/codex-structured-session-options' import { optionSession } from '../../codex/codex-structured-option-session.test-fixture' @@ -11,7 +14,6 @@ import { claudeFallbackModelOptions } from '../../claude/claude-structured-sessi import { acquired, fakeClaude, - claudeFrame, USER_MESSAGE } from '../../claude/claude-structured-session-test-support' import { getAgentSessionOptionCatalog } from '../../../shared/agent-session-option-catalog' @@ -20,7 +22,6 @@ import { applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState } from '../../../shared/structured-agent-session-options' -import { structuredAgentSessionSendSelection } from '../../../shared/structured-agent-session-option-view' import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' import { describe, expect, it, vi } from 'vitest' @@ -103,12 +104,17 @@ describe('options at rest', () => { const current = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - expect(current.current).toEqual({ defaulted: [], permissionMode: 'ask' }) + expect(current.current).toMatchObject({ + defaulted: [], + permissionMode: 'ask', + acceptedInputSources: {} + }) expect(read).toHaveBeenCalledTimes(2) const older = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: false }) - expect(older.current).toEqual({ + expect(older.current).toMatchObject({ + acceptedInputSources: { model: 'policy' }, model: 'gpt-account', defaulted: [], permissionMode: 'ask' @@ -157,6 +163,9 @@ describe('options at rest', () => { expect(resting.listedModelIds).toEqual(['gpt-5.5']) expect(resting.current).toMatchObject({ model: 'gpt-unlisted', defaulted: [] }) expect(running.current).toMatchObject({ model: 'gpt-unlisted', defaulted: [] }) + for (const answer of [resting, running]) { + expect(answer.current.acceptedInputSources).toMatchObject({ model: 'saved', effort: 'saved' }) + } }) it.each(['omp', 'grok', 'opencode'] as const)( @@ -200,9 +209,10 @@ describe('options at rest', () => { expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } // Unregistered agents must not report a permission mode. - expect((await read()).current).toEqual({ + expect((await read()).current).toMatchObject({ model: 'later-model', effort: 'off', + acceptedInputSources: { model: 'saved', effort: 'saved' }, defaulted: [] }) for (const emptyStart of [false, true]) { @@ -304,8 +314,64 @@ describe('a chat at rest, read by the host', () => { ])('%s', async (_name, facts, items, wroteBeforeOpen, defaulted) => { const read = await readAtRest(facts, items, wroteBeforeOpen) expect(read.current).toMatchObject({ model: 'gpt-account', defaulted }) + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG), + CODEX_SESSION_OPTION_CATALOG, + read, + { newLaunch: true, coherentCatalog: true } + ) + expect(read.current.acceptedInputSources).toEqual(defaulted.length ? {} : { model: 'policy' }) + expect(structuredAgentSessionMessageSelection(state)).toEqual( + defaulted.length ? {} : { model: 'gpt-account' } + ) }) + it.each([ + ['unknown', {}, {}, {}], + ['known report only', { model: 'gpt-account' }, {}, {}], + [ + 'unknown with saved literal', + {}, + { model: 'retired-alias', effort: 'high' }, + { model: 'retired-alias', effort: 'high' } + ] + ] as const)( + 'keeps %s live Codex input source-qualified through metadata refresh', + async (_name, applied, saved, selection) => { + const native = optionSession(vi.fn()) + native.appliedTurnOptions = applied + const provider = await readLiveCodexSessionOptions(native, undefined) + expect(provider.current.model).toBe('model' in applied ? applied.model : undefined) + const answer = await readAtRest({ options: saved }, [], false, listed, true, provider) + expect(answer.current).toMatchObject({ + model: 'model' in saved ? saved.model : 'gpt-account', + defaulted: 'model' in applied || 'model' in saved ? [] : ['model'] + }) + let state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG), + CODEX_SESSION_OPTION_CATALOG, + answer, + { newLaunch: true, coherentCatalog: true } + ) + expect(answer.current.acceptedInputSources).toEqual( + 'model' in saved ? { model: 'saved', effort: 'saved' } : {} + ) + expect(structuredAgentSessionMessageSelection(state)).toEqual(selection) + state = applyStructuredAgentSessionModelCatalog( + state, + CODEX_SESSION_OPTION_CATALOG, + { + origin: 'probe', + fetchedAt: 1, + models: listed, + listingNamesConfiguredModel: true + }, + { newLaunch: true, coherentCatalog: true } + ) + expect(structuredAgentSessionMessageSelection(state)).toEqual(selection) + } + ) + it('preserves sparse Codex knowledge from an actual turn through host and shared desktop/phone values', async () => { const pending = Promise.withResolvers() const request = vi.fn(() => pending.promise) @@ -758,74 +824,3 @@ describe('a chat at rest, read by the host', () => { } }) }) - -it('projects held Claude reports through the full reader while actual controls and Send proceed', async () => { - const events: ClaudeStructuredSessionEvent[] = [] - const native = fakeClaude() - const adapter = await acquired(native, {}, events) - await claudeOpened(adapter, SESSION) - const connection = native.connections[0]! - const pending = Promise.withResolvers() - const settings = vi.spyOn(connection, 'getSettings').mockReturnValueOnce(pending.promise) - const queue = new StructuredAgentSessionTaskQueue() - const record = { ...restingRecord(), provider: 'claude', options: {} } - const live = { child: { fence: 7 }, params: { provider: 'claude' }, journal: restingJournal() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the full reader uses only these context members; provider operations use the actual adapter. - const context = { - deps: { - adapter, - agents: claudeAndCodexAgents(), - store: hostTestOptionsReadStore(() => record) - }, - serialize: (id: string, task: () => Promise) => queue.serialize(id, task), - openConversation: async () => live, - conversation: async () => live - } as unknown as StructuredAgentSessionMutationContext - const read = () => - readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - try { - claudeFrame(connection, { type: 'result', subtype: 'success', result: 'done', is_error: false }) - await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce()) - expect((await read()).current.effort).toBe('high') - await queue.serialize(SESSION, () => - adapter.dispatch({ - sessionId: SESSION, - fence: 7, - clientMessageId: 'while-reporting', - body: USER_MESSAGE - }) - ) - expect(connection.sent.length).toBeGreaterThan(0) - await queue.serialize(SESSION, () => - adapter.setOption({ sessionId: SESSION, fence: 7, key: 'model', value: 'claude-opus-5' }) - ) - pending.resolve({ - applied: { model: 'old', effort: 'max' }, - effective: { effortLevel: 'max', fastMode: true } - }) - await Promise.resolve() - await Promise.resolve() - expect((await read()).current).toMatchObject({ model: 'claude-opus-5', effort: 'high' }) - settings.mockResolvedValueOnce({ - applied: { model: 'claude-opus-5', effort: 'low' }, - effective: { effortLevel: 'low', fastMode: true } - }) - claudeFrame(connection, { - type: 'result', - subtype: 'success', - result: 'later', - is_error: false - }) - await vi.waitFor(async () => - expect((await read()).current).toMatchObject({ effort: 'low', fastMode: true }) - ) - expect( - events.some( - (event) => event.type === 'options-reported' && event.reportedOptions.effort === 'max' - ) - ).toBe(false) - } finally { - pending.resolve(null) - await adapter.closeSession(SESSION) - } -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index b8d4e05e5b8..fb3f6aa8c34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -10,6 +10,7 @@ import { structuredAgentSessionThreadGoalReport } from './structured-agent-sessi // Saved choices, catalog defaults and actual execution reports remain separate facts. import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { AgentSessionAcceptedInputSources } from '../../../shared/agent-session-options-result' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' import { @@ -101,10 +102,8 @@ export async function readStructuredAgentSessionOptions( ...options.current, ...(model !== undefined ? { model } : {}), effort: saved.effort ?? options.current.effort, - fastMode: - saved.fastMode !== undefined - ? selected.current.fastMode - : (options.current.fastMode ?? selected.current.fastMode), + fastMode: selected.current.fastMode ?? options.current.fastMode, + // The saved tier as the shared speed rule runs it under the saved model. serviceTier: saved.serviceTier !== undefined ? selected.current.serviceTier @@ -115,11 +114,26 @@ export async function readStructuredAgentSessionOptions( key === 'model' && !options.current.model ? displayDefault !== undefined || selected.current.defaulted?.includes(key) === true : options.current.defaulted?.includes(key) === true - const defaulted = - (agent !== 'codex' || !live || options.current.model) && - structuredAgentSessionNeverSent(record, session.journal) - ? [] - : (['model', 'effort'] as const).filter((key) => saved[key] === undefined && defaultedBy(key)) + const neverSent = structuredAgentSessionNeverSent(record, session.journal) + const defaultKeys = (['model', 'effort'] as const).filter( + (key) => saved[key] === undefined && defaultedBy(key) + ) + const pinsDefaults = neverSent && (agent !== 'codex' || !live || !!options.current.model) + const defaulted = pinsDefaults ? [] : defaultKeys + const acceptedInputSources: AgentSessionAcceptedInputSources = {} + for (const key of ['model', 'effort', 'fastMode', 'serviceTier'] as const) { + if (current[key] !== undefined && saved[key] !== undefined) { + acceptedInputSources[key] = 'saved' + } + } + for (const key of defaultKeys) { + if (pinsDefaults && current[key] !== undefined) { + acceptedInputSources[key] = 'policy' + } + } + if (saved.fastMode === undefined && selected.current.fastMode === false) { + acceptedInputSources.fastMode = 'policy' + } const confirmed = options.current.confirmed?.filter((key) => key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier' ? current[key] === options.current[key] @@ -166,6 +180,7 @@ export async function readStructuredAgentSessionOptions( : {}), current: { ...current, + acceptedInputSources, defaulted, confirmed, ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 7b5f24772e7..f4478cd2abc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -111,7 +111,8 @@ export async function maybeQueueStructuredAgentSessionSend( delivery?: 'queue-if-active' queuedImageCards?: boolean options?: Readonly> - /** Require retained attachments for personal chat sends. */ + selection?: Readonly> + /** A person's send at a chat surface: every attachment it names must still be stored. */ userSend?: true personsMessage?: true } @@ -153,7 +154,7 @@ export async function maybeQueueStructuredAgentSessionSend( if (refusal) { return { ok: false, refusal } } - const captured = captureStructuredExecutionOptions(ctx, params.options) + const captured = captureStructuredExecutionOptions(ctx, params.options, params.selection) if ('refusal' in captured) { return { ok: false, refusal: captured.refusal } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts index d80e1a0b550..6b95124ba9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resting-options.ts @@ -107,7 +107,9 @@ export async function readStructuredAgentSessionOptionsAtRest( : models const fastMode = saved.fastMode === undefined - ? null + ? rules?.acceptsKey('fastMode') + ? false + : null : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) // An unknown model is one the client already treats as unconfirmed. Only a real listing names the // account's default; a built-in list's default is a guess, so with none the client keeps its own. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts index b666306256f..66dbf8d7847 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts @@ -1,6 +1,11 @@ +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' +import { + agentSessionMessagePayload, + agentSessionSendBodyFingerprint +} from '../../../shared/structured-agent-session-send-mutation' import { createQueuedMessageTestRig, eventually, @@ -9,6 +14,8 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW, + HOST_TEST_THREAD as THREAD, hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' @@ -141,6 +148,111 @@ describe('an opening message created with a model and effort', () => { }) }) +const openingSelections: (Readonly> | undefined)[] = [ + undefined, + {}, + { model: 'shown-model', effort: 'high', serviceTier: 'default' } +] + +it.each(openingSelections)( + 'aliases the real opening echo by body and replays the immutable create selection %j', + async (selection) => { + const rest = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + try { + const accepted = Promise.withResolvers>() + rest.adapter.dispatch.mockImplementationOnce(() => accepted.promise) + const saved = { model: 'saved-model', effort: 'low', personality: 'friendly' } + const first: StructuredAgentSessionFirstMessage = { + clientMessageId: hostTestOperationId(), + body: hostTestMessage('opening echo'), + ...(selection !== undefined ? { selection } : {}) + } + const params = createTestParams(first, { options: saved }) + const envelope = structuredClone(params.envelope) + const created = await rest.host.create(CREATE_TEST_CALLER, params, { firstMessage: first }) + expect(created.ok).toBe(true) + await vi.waitFor(() => expect(rest.adapter.dispatch).toHaveBeenCalledOnce()) + const expected = selection === undefined ? saved : { personality: 'friendly', ...selection } + expect(rest.adapter.dispatch.mock.calls[0][0].executionOptions).toEqual(expected) + const events = rest.adapter.acquire.mock.calls[0][0].events! + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'root-turn', ordinal: 2 }, + first.body, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rest.host.flushStreamedEvents(SESSION) + accepted.resolve({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: 'predicted-turn', + ordinal: 0 + } + }) + await vi.waitFor(async () => + expect((await rest.host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe( + 'accepted' + ) + ) + const snapshot = await rest.host.journalSnapshot(SESSION) + expect( + snapshot.items.filter((item) => item.body.kind === 'message' && item.body.role === 'user') + ).toHaveLength(1) + expect(snapshot.submissions).toMatchObject([ + { + clientMessageId: first.clientMessageId, + payloadFingerprint: agentSessionSendBodyFingerprint(SESSION, first.body), + options: expected + } + ]) + await rest.store.replaceSessionOptions({ + sessionId: SESSION, + fence: rest.store.getRecord(SESSION)!.lease.runtimeFence, + now: HOST_TEST_NOW, + options: { model: 'new-default', effort: 'medium', personality: 'changed' } + }) + const changedFirst: StructuredAgentSessionFirstMessage = { + ...first, + selection: selection?.model ? {} : { model: 'changed-control' } + } + const changedParams = createTestParams(changedFirst, { options: saved }) + expect(changedParams.envelope.payloadFingerprint).not.toBe(envelope.payloadFingerprint) + const conflict = await rest.host.create( + CREATE_TEST_CALLER, + { + ...changedParams, + envelope: { ...envelope, payloadFingerprint: changedParams.envelope.payloadFingerprint } + }, + { firstMessage: changedFirst } + ) + expect(conflict).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + const replay = await rest.host.create(CREATE_TEST_CALLER, params, { firstMessage: first }) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { + firstMessage: { + clientMessageId: first.clientMessageId, + payloadFingerprint: agentSessionSendBodyFingerprint(SESSION, first.body), + options: expected + } + } + }) + expect(params.envelope).toEqual(envelope) + expect(first.selection).toEqual(selection) + expect((await rest.host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(rest.adapter.dispatch).toHaveBeenCalledOnce() + expect(rest.adapter.dispatch.mock.calls[0][0].executionOptions).toEqual(expected) + } finally { + await rest.dispose() + } + } +) + describe('a send carrying its model and effort', () => { it('fingerprints them with the operation, so the same id with other options conflicts', async () => { const body = hostTestMessage('once') @@ -182,3 +294,63 @@ describe('a send kept as a card because Orca stopped before handing it over', () } }) }) + +it('captures the shown tuple despite unseen saved picks, preserves personality, and distinguishes empty exact from legacy capture', async () => { + const saved = { + model: 'hidden-model', + effort: 'low', + serviceTier: 'priority', + personality: 'friendly' + } + await rig.store.replaceSessionOptions({ + sessionId: SESSION, + fence: 1, + now: HOST_TEST_NOW, + options: saved + }) + const send = (selection?: Record, delivery?: 'queue-if-active') => { + const body = hostTestMessage('shown selection') + const fields = { + body, + ...(selection !== undefined ? { selection } : {}), + ...(delivery ? { delivery } : {}) + } + const params = { + ...fields, + envelope: rig.envelope(fields, 'agentSession.send', hostTestOperationId()) + } + return { params, result: rig.host.send(CALLER, params) } + } + const shown = { model: 'shown-model', effort: 'high', serviceTier: 'default' } + const exact = send(shown) + expect(await exact.result).toMatchObject({ + ok: true, + value: { submission: { options: { ...shown, personality: 'friendly' } } } + }) + expect(rig.store.getRecord(SESSION)?.options).toEqual(saved) + const empty = send({}) + expect(await empty.result).toMatchObject({ + ok: true, + value: { submission: { options: { personality: 'friendly' } } } + }) + expect((await rig.submission(empty.params.envelope.clientOperationId))?.options).toEqual({ + personality: 'friendly' + }) + expect(await send().result).toMatchObject({ ok: true, value: { submission: { options: saved } } }) + const changed = { + ...exact.params, + selection: {}, + envelope: { + ...exact.params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body: exact.params.body, selection: {} } + }) + } + } + expect(await rig.host.send(CALLER, changed)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index 8e1f2503166..9a7c07cc9c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -28,7 +28,8 @@ export function mintStructuredAgentSessionStartupAttempt(input: { attemptId?: string }): StructuredAgentSessionStartupAttempt { const { record } = input - const options = input.startupOptions ?? record.options + const selected = input.startupOptions ?? record.options + const options = record.provider === 'claude' ? { fastMode: 'false', ...selected } : selected return { attemptId: input.attemptId ?? randomUUID(), identity: input.identity, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 1a594d95eba..416303df0a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -30,6 +30,9 @@ export function structuredStatusSummariesEqual( a.runtimePermission?.fence === b.runtimePermission?.fence && a.runtimePermission?.mode === b.runtimePermission?.mode && (a.runtimePermission === undefined) === (b.runtimePermission === undefined) && + a.effort === b.effort && + a.fastMode === b.fastMode && + a.serviceTier === b.serviceTier && a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 223aa507315..14e60ebccf8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -53,6 +53,10 @@ export function structuredAgentSessionStatusSummary({ // Acknowledged record options remain authoritative across model switches. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) const permissionMode = record?.permissionMode + // Published so another device's pick moves the client's option stamp. + const effort = record?.options?.effort + const fastMode = record?.options?.fastMode + const serviceTier = record?.options?.serviceTier const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined return { sessionId, @@ -74,6 +78,9 @@ export function structuredAgentSessionStatusSummary({ : {}), ...(model ? { model } : {}), ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode } : {}), + ...(serviceTier ? { serviceTier } : {}), ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index b77152d1afc..61bfb153fe3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -93,6 +93,7 @@ export async function performSend( /** Persist only source kind in the submission. */ source?: AgentSessionMessageSource options?: Readonly> + selection?: Readonly> } ): Promise> { const existing = ctx.journal @@ -110,7 +111,7 @@ export async function performSend( value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } - const captured = captureStructuredExecutionOptions(ctx, input.options) + const captured = captureStructuredExecutionOptions(ctx, input.options, input.selection) if ('refusal' in captured) { return { ok: false, refusal: captured.refusal } } diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 529c2a4b82c..3a68caad212 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -287,6 +287,13 @@ async function newChatFrames( return { atRest, first: firstPills, settled: pills(settled) } } +const AT_REST_METADATA = { + confirmed: undefined, + fastMode: undefined, + serviceTier: undefined, + reportedPermissionMode: undefined +} + describe("a new chat's effort pill before its agent starts", () => { it.each([ ['codex', 'gpt-5.5', 'medium'], @@ -300,7 +307,14 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBe(model) expect(first.effort?.value).toBe(effort) expect(settled).toEqual(first) - expect(atRest.current).toEqual({ model, effort, defaulted: [], ...modeOf(agent) }) + expect(atRest.current).toStrictEqual({ + model, + effort, + defaulted: [], + ...modeOf(agent), + ...AT_REST_METADATA, + acceptedInputSources: { model: 'policy', effort: 'policy' } + }) } ) @@ -314,7 +328,13 @@ describe("a new chat's effort pill before its agent starts", () => { expect(first.model?.value).toBeUndefined() expect(first.effort).toBeNull() expect(settled).toEqual(first) - expect(atRest.current).toEqual({ defaulted: [], ...modeOf(agent) }) + expect(atRest.current).toStrictEqual({ + defaulted: [], + ...modeOf(agent), + effort: undefined, + ...AT_REST_METADATA, + acceptedInputSources: {} + }) } ) @@ -324,11 +344,13 @@ describe("a new chat's effort pill before its agent starts", () => { readsWithoutModel: false }) - expect(atRest.current).toEqual({ + expect(atRest.current).toStrictEqual({ model: 'gpt-5.5', effort: 'high', defaulted: [], - permissionMode: 'ask' + permissionMode: 'ask', + ...AT_REST_METADATA, + acceptedInputSources: { model: 'policy', effort: 'policy' } }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) }) @@ -336,22 +358,26 @@ describe("a new chat's effort pill before its agent starts", () => { it('names the folder’s default effort for a client that needs a model too', async () => { const { atRest } = await newChatFrames('codex', { readsWithoutModel: false }) - expect(atRest.current).toEqual({ + expect(atRest.current).toStrictEqual({ model: 'gpt-5.5', effort: 'medium', defaulted: [], - permissionMode: 'ask' + permissionMode: 'ask', + ...AT_REST_METADATA, + acceptedInputSources: { model: 'policy', effort: 'policy' } }) }) it('keeps a saved effort over the default model’s', async () => { const { atRest, settled } = await newChatFrames('grok', { saved: { effort: 'high' } }) - expect(atRest.current).toEqual({ + expect(atRest.current).toStrictEqual({ model: 'grok-4', effort: 'high', defaulted: [], - ...modeOf('grok') + ...modeOf('grok'), + ...AT_REST_METADATA, + acceptedInputSources: { model: 'policy', effort: 'saved' } }) expect(settled.effort?.value).toBe('high') }) @@ -359,6 +385,13 @@ describe("a new chat's effort pill before its agent starts", () => { it('leaves the effort of a saved model to the agent, as its running child reports it', async () => { const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } }) - expect(atRest.current).toEqual({ model: 'gpt-5.5-mini', defaulted: [], permissionMode: 'ask' }) + expect(atRest.current).toStrictEqual({ + model: 'gpt-5.5-mini', + defaulted: [], + permissionMode: 'ask', + effort: undefined, + ...AT_REST_METADATA, + acceptedInputSources: { model: 'saved' } + }) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.mid-turn.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.mid-turn.test.tsx new file mode 100644 index 00000000000..522f743444d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.mid-turn.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +const rpc = vi.hoisted(() => ({ call: vi.fn() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc.call, + subscribeStructuredAgentSessionStatus: async () => ({ unsubscribe: () => {} }) +})) +vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ + useStructuredAgentSessionHostCapability: () => true, + useStructuredAgentSessionOwnPermissionState: () => 'supported', + structuredAgentSessionHostKey: () => 'local' +})) +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: () => Promise.resolve() +})) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replaceAll(`{{${key}}}`, String(value)), + fallback + ) +})) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' +import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' + +const target = { kind: 'local' } as const +const sessionId = 'mid-turn-picker' +afterEach(() => { + cleanup() + forgetStructuredSessionSelection(sessionId) +}) + +it('picks effort during a running turn, saves it to the chat, and captures it only for the next message', async () => { + let effort = 'high' + const sent: unknown[] = [] + const answer = () => ({ + models: [ + { + id: 'opus', + label: 'Opus', + efforts: ['low', 'high'].map((value) => ({ value, label: value })) + } + ], + current: { model: 'opus', effort, acceptedInputSources: { model: 'saved', effort: 'saved' } } + }) + rpc.call.mockImplementation(async (_target, method, input) => { + if (method === 'agentSession.options') { + return answer() + } + if (method === 'agentSession.modelCatalog') { + return { origin: 'unknown' } + } + if (method === 'agentSession.setOption') { + effort = input.value + return { ok: true, value: { ...input, options: { model: 'opus', effort } } } + } + throw new Error(`Unexpected RPC ${method}`) + }) + function Composer({ working = true }: { working?: boolean }) { + const options = useStructuredAgentSessionOptions({ + agent: 'claude', + sessionId, + target, + fence: 1, + transportEnabled: true, + isVisible: true, + providerVisible: true, + providerRunning: true, + turnId: working ? 'running-turn' : null, + unloadedTurnRevisions: undefined, + mutate: async () => null + }) + const capture = useStructuredAgentSessionSendOptions(sessionId, undefined, options.sendSource) + return ( + + + + + ) + } + const { rerender } = render() + await waitFor(() => + expect(screen.getByRole('button', { name: /Model Opus/ }).textContent).toContain('High') + ) + fireEvent.click(screen.getByRole('button', { name: 'Send next message' })) + const running = sent[0] + const trigger = screen.getByRole('button', { name: /Model Opus/ }) + expect(trigger).toHaveProperty('disabled', false) + fireEvent.click(trigger) + fireEvent.click(await screen.findByRole('radio', { name: 'Low' })) + await waitFor(() => + expect( + rpc.call.mock.calls.some( + ([, method, input]) => + method === 'agentSession.setOption' && input.key === 'effort' && input.value === 'low' + ) + ).toBe(true) + ) + await waitFor(() => + expect(screen.getByRole('button', { name: /Model Opus/ }).textContent).toContain('Low') + ) + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Send next message' })) + expect(sent[1]).toEqual({ selection: { model: 'opus', effort: 'low' } }) + expect(running).toEqual({ selection: { model: 'opus', effort: 'high' } }) + expect( + rpc.call.mock.calls.every(([, method]) => !['turn/steer', 'turn/start'].includes(method)) + ).toBe(true) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index eb9bea32a67..9959db6c698 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -287,7 +287,7 @@ describe('NativeChatSessionOptionPickers', () => { expect(pill('Model Opus 4.8, Session options Fast').textContent).toBe('Opus 4.8Fast') }) - it('disables the pill while the agent is working', () => { + it('keeps terminal command pickers disabled while the agent is working', () => { render( ) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 62a2b2dbf0f..d15a0a844c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -205,7 +205,7 @@ function SessionOptionPopover(props: { ? `${modelName}, ${pillAccessibleName(optionsTitle, optionsLabel)}` : modelName } - disabled={props.isWorking} + disabled={props.isWorking && model.transport !== 'agent-session'} disabledReason={modelReason ?? optionsReason} dispatched={ sessionOptionDispatchUnconfirmed(model) || options.some(sessionOptionDispatchUnconfirmed) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-settings-write.ts b/src/renderer/src/components/native-chat/native-chat-session-option-settings-write.ts index 1c427c534cb..942f122961b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-settings-write.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-settings-write.ts @@ -5,9 +5,10 @@ import type { RuntimeClientTarget } from '../../../../shared/runtime-client-targ /** Apply deltas at the owning runtime so desktop/mobile choices cannot replace each other’s records. */ export function enqueueSessionOptionSettingsWrite( target: RuntimeClientTarget, - mutation: NativeChatSessionOptionSettingsMutation + mutation: NativeChatSessionOptionSettingsMutation, + options: { expectedEnvironmentPairingRevision?: number } = {} ): Promise { - return callRuntimeRpc(target, 'settings.mutateNativeChatSessionOptions', mutation) + return callRuntimeRpc(target, 'settings.mutateNativeChatSessionOptions', mutation, options) .then(() => undefined) .catch(() => undefined) } diff --git a/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx b/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx index a01b0eca5de..6472a823d06 100644 --- a/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-subscriptions.performance.test.tsx @@ -2,7 +2,11 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { createStructuredSessionSelectionController } from '../../../../shared/structured-agent-session-selection-operations' +import { useStructuredSessionSelectionReads } from './use-structured-session-selection-reads' +import { useStructuredAgentSessionOptionStamp } from './structured-agent-session-status-hooks' import { useAppStore } from '@/store' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import type { AppState } from '@/store/types' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' import { @@ -609,3 +613,254 @@ describe('native chat store subscription performance', () => { console.info('native-chat-dense-subscription-work', JSON.stringify(results)) }) }) + +it('bounds the actual pick/read/overlay path at 10 and 100 delayed writes without whole-drain joins', async () => { + const measurements: { + count: number + sends: number + renders: number + visits: number + memberships: number + }[] = [] + const target = { kind: 'local' } as const + for (const count of [10, 100]) { + const controller = createStructuredSessionSelectionController(vi.fn()) + const replies = Array.from({ length: count }, () => + Promise.withResolvers<{ + kind: 'accepted' + options: Record + }>() + ) + const reread = vi.fn() + const capture = vi.spyOn(controller, 'captureRead') + let renders = 0 + let visits = 0 + let sends = 0 + let memberships = 0 + const all = Promise.all.bind(Promise) + const join = vi.spyOn(Promise, 'all').mockImplementation((values) => { + const entries = Array.from(values) + memberships += entries.length + return all(entries) + }) + function Probe() { + useStructuredSessionSelectionReads({ + controller, + target, + authority: 'local', + hostOptionStamp: '', + turnId: null, + rereadOptions: reread + }) + controller.overlay() + controller.isPending('model') + controller.pendingId() + renders++ + return null + } + const view = render() + const baseline = renders + for (let index = 0; index < count; index++) { + act(() => { + const operation = controller.issue('model', index % 2 ? 'sonnet' : 'opus', null) + for (const key of ['identity', 'key', 'transmitted', 'outcome'] as const) { + let value = operation[key] + Object.defineProperty(operation, key, { + configurable: true, + get() { + visits++ + return value + }, + set(next) { + value = next + } + }) + } + controller.flush(() => { + sends++ + return replies[index].promise + }) + }) + } + join.mockRestore() + expect(sends).toBe(count) + expect(renders - baseline).toBe(count) + expect(memberships).toBe(0) + expect(visits).toBeLessThanOrEqual(40 * count) + expect(capture).not.toHaveBeenCalled() + expect(reread).not.toHaveBeenCalled() + measurements.push({ count, sends, renders: renders - baseline, visits, memberships }) + await act(async () => + replies[count - 1].resolve({ kind: 'accepted', options: { model: 'sonnet' } }) + ) + expect(reread).toHaveBeenCalledTimes(1) + const captured = controller.captureRead() + await act(async () => replies[1].resolve({ kind: 'accepted', options: { model: 'sonnet' } })) + expect(reread).toHaveBeenCalledTimes(2) + act(() => controller.retire(captured)) + expect(controller.captureRead()).toEqual([2]) + expect(controller.overlay()).toEqual({}) + const draining = controller.drain() + act(() => controller.abandon()) + await draining + for (const reply of replies) { + reply.resolve({ kind: 'accepted', options: { model: 'opus' } }) + } + await act(async () => { + await Promise.resolve() + }) + expect(controller.overlay()).toEqual({}) + view.unmount() + } + expect(measurements[1].visits).toBeLessThanOrEqual(measurements[0].visits * 11) + console.info('selection-pick-upstream-work', JSON.stringify(measurements)) +}) + +it('keeps unrelated store, same-host status and other-host consumers quiet at N and 10N', async () => { + for (const count of [10, 100]) { + const emits = new Map void>() + handOffTransport.subscribe.mockImplementation(async (target, emit) => { + emits.set(target.kind === 'local' ? 'local' : target.environmentId, emit) + return { unsubscribe: vi.fn() } + }) + const target = { kind: 'local' } as const + const feed = getStructuredAgentSessionStatusFeed(target) + const snapshot = vi.spyOn(feed, 'getSnapshot') + const other = getStructuredAgentSessionStatusFeed({ + kind: 'environment', + environmentId: 'other' + }) + const controllers = Array.from({ length: count }, () => + createStructuredSessionSelectionController(vi.fn()) + ) + const renders = Array.from({ length: count }, () => 0) + const rereads = controllers.map(() => vi.fn()) + function Probe({ index }: { index: number }) { + const stamp = useStructuredAgentSessionOptionStamp(`session-${index}`, target) + useStructuredSessionSelectionReads({ + controller: controllers[index], + target, + authority: 'local', + hostOptionStamp: stamp, + turnId: null, + rereadOptions: rereads[index] + }) + renders[index]++ + return null + } + const view = render(controllers.map((_, index) => )) + let stop = () => {} + await act(async () => { + stop = other.activate() + }) + const baseline = [...renders] + const publish = (host: string, sessionId: string, model: string) => { + const emit = emits.get(host) + if (!emit) { + throw new Error('Missing test status transport') + } + act(() => + emit({ + type: 'snapshot', + sessions: [ + { + sessionId, + workspaceId: 'wt-1', + agent: 'codex', + status: 'idle', + latestPrompt: 'hello', + updatedAt: 1, + effort: model + } + ] + }) + ) + } + snapshot.mockClear() + act(() => useAppStore.setState({ lastTerminalInputAtByPaneKey: { other: 1 } })) + expect(snapshot).not.toHaveBeenCalled() + publish('other', 'session-0', 'other-model') + expect(snapshot).not.toHaveBeenCalled() + 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(rereads[0]).toHaveBeenCalledTimes(1) + const beforePick = [...renders] + act(() => controllers[1].issue('model', 'opus', null)) + expect(renders).toEqual(beforePick.map((value, index) => value + (index === 1 ? 1 : 0))) + console.info( + 'selection-status-upstream-work', + JSON.stringify({ count, upstreamReads, unrelatedRenders: 0 }) + ) + view.unmount() + stop() + snapshot.mockRestore() + resetStructuredAgentSessionStatusFeedsForTests() + } +}) + +it('bounds selected continuity wakes and keeps other-host consumers quiet at N and 10N', () => { + for (const count of [10, 100]) { + const targets = Array.from({ length: count }, (_, index) => ({ + kind: 'environment' as const, + environmentId: index % 2 ? 'continuity-other' : 'continuity-selected' + })) + const controllers = targets.map(() => createStructuredSessionSelectionController(vi.fn())) + const reads = targets.map(() => vi.fn()) + const renders = targets.map(() => 0) + function Probe({ index }: { index: number }) { + useStructuredSessionSelectionReads({ + controller: controllers[index], + target: targets[index], + authority: '', + hostOptionStamp: '', + turnId: null, + rereadOptions: reads[index] + }) + renders[index]++ + return null + } + const environments = targets + .slice(0, 2) + .map((target) => ({ id: target.environmentId, createdAt: 1, pairingRevision: 2 })) + replaceRuntimeEnvironmentRevisions(environments, { + retired: [], + sameHost: [], + pending: [{ id: 'continuity-selected', fromRevision: 1 }] + }) + const view = render(targets.map((_, index) => )) + const baseline = [...renders] + const projectionReads = vi.spyOn(useAppStore.getState().runtimeStatusByEnvironmentId, 'get') + act(() => useAppStore.setState({ lastTerminalInputAtByPaneKey: { other: count } })) + expect(projectionReads).not.toHaveBeenCalled() + const publishCatalog = () => + useAppStore.setState({ runtimeEnvironments: [...useAppStore.getState().runtimeEnvironments] }) + act(publishCatalog) + expect(renders).toEqual(baseline) + expect(projectionReads.mock.calls.length).toBeLessThanOrEqual(2 * count) + replaceRuntimeEnvironmentRevisions(environments, { + retired: [], + sameHost: [{ id: 'continuity-selected', fromRevision: 1, toRevision: 2 }] + }) + act(publishCatalog) + expect(renders).toEqual(baseline.map((value, index) => value + (index % 2 ? 0 : 1))) + expect(reads.map((read) => read.mock.calls.length)).toEqual( + targets.map((_, index) => (index % 2 ? 0 : 1)) + ) + console.info( + 'selection-continuity-work', + JSON.stringify({ + count, + projectionReads: projectionReads.mock.calls.length, + otherHostRenders: 0 + }) + ) + view.unmount() + projectionReads.mockRestore() + } + replaceRuntimeEnvironmentRevisions([]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-option-first-frame.ts b/src/renderer/src/components/native-chat/structured-agent-session-option-first-frame.ts new file mode 100644 index 00000000000..bc4917b4ab6 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-option-first-frame.ts @@ -0,0 +1,22 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + type StructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' + +/** The first frame: the host's saved list when the renderer already holds it, else the seed, + * which shows the quiet placeholder until the host answers. */ +export function firstFrameOptionState( + agent: AgentType, + target: RuntimeClientTarget, + launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean } +): StructuredAgentSessionOptionState { + const seed = structuredAgentSessionSeedCatalog(agent) + const seeded = createStructuredAgentSessionOptionState(agent, seed) + const snapshot = readHostModelCatalogSnapshot(target, agent, launch) + return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts b/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts index 99b2ff1313b..f91a4660712 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-options-contract.ts @@ -1,4 +1,7 @@ -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { + CaptureStructuredAgentSessionPresentation, + StructuredAgentSessionMutate +} from './use-structured-agent-session-mutate' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import type { AgentType } from '../../../../shared/agent-status-types' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' @@ -16,6 +19,23 @@ export type StructuredAgentSessionOptionsArgs = { fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined + capturePresentation?: CaptureStructuredAgentSessionPresentation mutate: StructuredAgentSessionMutate launch?: StructuredAgentSessionLaunchView } + +import type { AgentSessionConversationCommand } from '../../../../shared/agent-session/agent-session-conversation-command' +import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' +import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' + +export type StructuredSessionConversationSupport = { + sessionId: string + commands: readonly AgentSessionConversationCommand[] + threadGoal: AgentSessionOptionsResult['threadGoal'] + reportedThreadGoal: AgentSessionOptionsResult['reportedThreadGoal'] + contextUsage: AgentSessionOptionsResult['contextUsage'] + acceptsImages: boolean | undefined + rewind: AgentSessionRewindSupport + /** The fence the read answered for; rewind support is only that runtime's. */ + fence: number | null +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts index ac79fcf926b..6a01fd64db4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -10,6 +10,7 @@ export type StructuredAgentSessionPendingSend = { previewUris: readonly string[] queuedAt: number delivery?: 'queue-if-active' + selection?: Readonly> options?: Readonly> usageLimitContinuationOfTurnItemId?: string /** Sending blocks new sends; recorded releases the slot but retains its echo until host rows/cards arrive. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts index 30c43c03e7b..e6a26dac8c4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts @@ -133,6 +133,7 @@ export async function attemptStructuredAgentSessionSend(args: { body: entry.body, ...(entry.delivery ? { delivery: entry.delivery } : {}), ...(entry.options ? { options: entry.options } : {}), + ...(entry.selection !== undefined ? { selection: entry.selection } : {}), ...(entry.usageLimitContinuationOfTurnItemId ? { usageLimitContinuationOfTurnItemId: entry.usageLimitContinuationOfTurnItemId } : {}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts index 8145b6e1579..fba19370abe 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts @@ -20,6 +20,7 @@ export type StructuredAgentSessionSendInput = { /** Asks the host to hold it as a card while the agent works; decided once, at the send. */ delivery?: 'queue-if-active' /** The composer's model and effort for the turn it starts, read once at the send. */ + selection?: Readonly> options?: Readonly> /** The caller keeps the text if it comes back, instead of the chat's composer. */ callerKeepsText?: true @@ -51,7 +52,8 @@ export function takeStructuredAgentSessionSendSlot( previewUris: attachments.map((attachment) => attachment.previewUri), queuedAt: input.now ?? Date.now(), ...(input.delivery ? { delivery: input.delivery } : {}), - ...(input.options ? { options: input.options } : {}), + ...(input.options ? { options: { ...input.options } } : {}), + ...(input.selection !== undefined ? { selection: { ...input.selection } } : {}), ...(input.usageLimitContinuationOfTurnItemId ? { usageLimitContinuationOfTurnItemId: input.usageLimitContinuationOfTurnItemId } : {}), diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx index 321d8ca2fdc..19f0af880c1 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx @@ -1,7 +1,9 @@ +import { useAppStore } from '@/store' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' // @vitest-environment happy-dom -import { act, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -60,7 +62,7 @@ function renderOptions(initial: Props = {}) { turnId: null, unloadedTurnRevisions: undefined, mutate, - launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} } + launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' } } }), { initialProps: initial } ) @@ -116,8 +118,28 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { const flush = (): Promise => act(async () => {}) +const initialStore = useAppStore.getState() +afterEach(() => { + cleanup() + forgetStructuredSessionSelection(sessionId, PAIRED_TARGET) + forgetStructuredSessionSelection(`${sessionId}-other`, PAIRED_TARGET) + useAppStore.setState(initialStore, true) +}) describe('host model catalog read', () => { beforeEach(() => { + useAppStore.getState().setRuntimeEnvironments([ + { + id: PAIRED_TARGET.environmentId, + name: 'catalog host', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'loopback' + } + ]) mocks.call.mockReset() resetHostModelCatalogSnapshotsForTests() sessionCount += 1 @@ -203,6 +225,7 @@ describe('host model catalog read', () => { ) const { result, unmount } = renderOptions({ attached: true }) await flush() + expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.options')).toBe(true) live.resolve({ models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], current: { model: 'gpt-live', confirmed: ['model'] } diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx index 7d39b6898a6..ae5afe9fcee 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx @@ -62,7 +62,7 @@ function renderOptions() { ...(providerRunning ? { providerRunning } : {}), unloadedTurnRevisions: undefined, mutate, - launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} } + launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' } } }), { initialProps: NO_TURN } ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 2ab59b3d5d7..060d501d282 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -16,16 +16,12 @@ export type StructuredAgentSessionLaunchView = { /** The encoded selection the launch seeded, shown until the host names the model. */ seedOptions?: Readonly> explicitOptions?: Readonly> - /** Picks the launch holds and applies before it publishes. */ - heldOptions: Readonly> /** Where the chat runs; its own config may replace the listed default. */ worktree?: string /** Its paired server has not said what create seeds yet; picks wait for it. */ seedPending?: true } -const NO_HELD_OPTIONS: Readonly> = {} - function useLaunchView( sessionId: string, worktreeId: string | null | undefined, @@ -37,8 +33,8 @@ function useLaunchView( const current = getStructuredAgentSessionLaunchSelection(sessionId) if (current) { snapshot = current - } else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) { - snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS, seedPending: true } + } else if (snapshot && !snapshot.seedPending) { + snapshot = { seed: snapshot.seed, seedPending: true } } return snapshot } @@ -70,7 +66,6 @@ function useLaunchView( : null }) } - const held = selection?.held ?? NO_HELD_OPTIONS const seedPending = launching && (!selection || selection.seedPending === true) return useMemo( () => @@ -79,12 +74,11 @@ function useLaunchView( kind: intent.kind, ...(selection?.seed ? { seedOptions: selection.seed } : {}), ...(seedPending ? { seedPending: true as const } : {}), - heldOptions: held, ...(selection?.explicit ? { explicitOptions: selection.explicit } : {}), ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, intent.kind, selection?.seed, selection?.explicit, seedPending, worktreeId] + [intent.kind, selection?.seed, selection?.explicit, seedPending, worktreeId] ) } 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 07394d14421..466a1fa3c0a 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 @@ -197,8 +197,8 @@ describe('useStructuredAgentSession context usage', () => { } expect(optionReads()).toBe(2) await act(async () => replies[1](160_000)) - // The one in flight lands, and one more read covers every revision behind it. - expect(result.current.contextUsage).toMatchObject({ usedTokens: 160_000 }) + // The obsolete read cannot replace the answer; one current successor covers the revisions. + expect(result.current.contextUsage).toMatchObject({ usedTokens: 150_000 }) await waitFor(() => expect(optionReads()).toBe(3)) await act(async () => replies[2](175_000)) expect(result.current.contextUsage).toMatchObject({ usedTokens: 175_000 }) 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 72a5701de67..66a72777c59 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,56 +1,46 @@ +import type { StructuredSessionConversationSupport } from './structured-agent-session-options-contract' import { - createStructuredOptionPickBook, - resetStructuredOptionPickBook, - structuredOptionPickIsPending -} from '../../../../shared/structured-agent-session-option-pick-book' + structuredSessionSelectionAuthority, + structuredSessionSelectionController +} from '@/lib/structured-agent-session-selection-controller' +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 { AgentSessionConversationCommand } from '../../../../shared/agent-session/agent-session-conversation-command' import type { - AgentSessionModelCatalogResult, - AgentSessionOptionsResult + AgentSessionOptionsResult, + AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' -import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' 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 { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' +import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, settleStructuredAgentSessionBuiltinCatalog, - createStructuredAgentSessionOptionState, 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 { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities' -import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { + AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY +} from '../../../../shared/agent-session-create-capabilities' import { useStructuredAgentSessionHostCapability, useStructuredAgentSessionOwnPermissionState, structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' import { cloneStructuredAgentSessionOptionRecord } from '../../../../shared/structured-agent-session-option-state' import { createCoalescedPollRunner, type CoalescedPollRunner } from '../right-sidebar/coalesced-poll-runner' -/** Use the cached host list on first paint, otherwise a quiet seed placeholder until the host answers. */ -export function firstFrameOptionState( - agent: AgentType, - target: RuntimeClientTarget, - launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean } -): StructuredAgentSessionOptionState { - const seed = structuredAgentSessionSeedCatalog(agent) - const seeded = createStructuredAgentSessionOptionState(agent, seed) - const snapshot = readHostModelCatalogSnapshot(target, agent, launch) - return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded -} +export { firstFrameOptionState } from './structured-agent-session-option-first-frame' +import { firstFrameOptionState } from './structured-agent-session-option-first-frame' /** The picker's option state for one session: seeded, kept across its fences, re-read each turn. */ export function useStructuredAgentSessionOptionState(args: { @@ -75,7 +65,6 @@ export function useStructuredAgentSessionOptionState(args: { const { agent, fence, - hostOptionStamp, identity, newLaunch, optionCatalog, @@ -85,21 +74,11 @@ export function useStructuredAgentSessionOptionState(args: { readsBeforeStart, sessionId, target, - turnId, worktree } = args const { summary, observation } = useStructuredAgentSessionStatusSummary(sessionId, target) - const [conversationSupport, setConversationSupport] = useState<{ - sessionId: string - commands: readonly AgentSessionConversationCommand[] - threadGoal: AgentSessionOptionsResult['threadGoal'] - reportedThreadGoal: AgentSessionOptionsResult['reportedThreadGoal'] - contextUsage: AgentSessionOptionsResult['contextUsage'] - acceptsImages: boolean | undefined - rewind: AgentSessionRewindSupport - /** The fence the read answered for; rewind support is only that runtime's. */ - fence: number | null - } | null>(null) + 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. @@ -114,11 +93,15 @@ export function useStructuredAgentSessionOptionState(args: { const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel }) useEffect(() => { firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel } - }, [target, newLaunch, worktree, seedsModel]) + }, [newLaunch, seedsModel, target, worktree]) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - // Picks on their way to the host, which every send carries too. - const optionPicks = useRef(createStructuredOptionPickBook()) + 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) @@ -134,41 +117,59 @@ export function useStructuredAgentSessionOptionState(args: { contextRef.current = contextKey }, [contextKey]) const isCurrentContext = useCallback(() => contextRef.current === contextKey, [contextKey]) - const writes = useRef({ count: 0, readback: false }) const coherentCatalog = usesInstalledAgentModelCatalog( agent, useStructuredAgentSessionHostCapability(target, AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY) ) - const optionIdentityRef = useRef(identity) + // 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, { + newLaunch, + coherentCatalog + }) + : current, + [newLaunch, optionCatalog, coherentCatalog] + ) + 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 - optionIdentityRef.current = identity + 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.catalogSource !== 'seed' && previous.catalogSource !== null + sameSession && + (previous.optionsAnswerPresent || + (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) ? { ...previous, - catalogSource: 'host' as const, record: cloneStructuredAgentSessionOptionRecord(previous.record), pendingId: null, reportedPermissionMode: undefined } : seeded const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept - writes.current = { count: 0, readback: false } - resetStructuredOptionPickBook(optionPicks.current) optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) - }, [agent, contextKey, fence, identity]) + }, [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) || !optionCatalog) { + if ( + !providerVisible || + (providerStarting && !readsBeforeStart && !takesSelection) || + !optionCatalog + ) { return } let stale = false @@ -176,23 +177,23 @@ export function useStructuredAgentSessionOptionState(args: { if (!isCurrentContext()) { return } - if (writes.current.count) { - writes.current.readback = true - return - } - writes.current.readback = false + const epoch = readEpoch.current const record = activeOptionRecordRef.current - const readGeneration = optionPicks.current.generation - const result = await callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } + const completed = controller.captureRead() + const result = await controller.request((options) => + callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId }, + options + ) ) if ( !stale && isCurrentContext() && - activeOptionRecordRef.current === record && - optionPicks.current.generation === readGeneration + readEpoch.current === epoch && + structuredSessionSelectionAuthority(target) === authority && + activeOptionRecordRef.current === record ) { setConversationSupport({ sessionId, @@ -205,13 +206,9 @@ export function useStructuredAgentSessionOptionState(args: { fence }) updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result, { - newLaunch, - coherentCatalog - }) - : current + current.record === record ? applyOptionsAnswer(current, result) : current ) + controller.retire(completed) } }) optionsReadRef.current = runner @@ -222,18 +219,18 @@ export function useStructuredAgentSessionOptionState(args: { optionsReadRef.current = null } }, [ - newLaunch, + applyOptionsAnswer, isCurrentContext, - coherentCatalog, + authority, + controller, + takesSelection, fence, optionCatalog, - hostOptionStamp, providerStarting, providerVisible, readsBeforeStart, sessionId, target, - turnId, updateOptionState ]) @@ -242,12 +239,24 @@ export function useStructuredAgentSessionOptionState(args: { useEffect(() => { if (contextRefresh !== seenContextRefresh.current) { seenContextRefresh.current = contextRefresh + readEpoch.current += 1 optionsReadRef.current?.run() } }, [contextRefresh]) - // An options read predating the account listing needs a fresh catalog-aware answer. - const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) + // An options answer made before the account's listing landed listed none of it. + const rereadOptions = useCallback(() => { + readEpoch.current += 1 + optionsReadRef.current?.run() + }, []) + useStructuredSessionSelectionReads({ + controller, + target, + authority, + hostOptionStamp: args.hostOptionStamp, + turnId: args.turnId, + rereadOptions + }) const receiveCatalog = ( catalog: AgentSessionModelCatalogResult | null, _event: 'demand' | 'progress' | 'settled' @@ -264,23 +273,24 @@ export function useStructuredAgentSessionOptionState(args: { rereadOptions() } } - const beginOptionWrite = (_id: string) => { - const record = activeOptionRecordRef.current - writes.current.count += 1 - return (readback: boolean) => { - if (!isCurrentContext() || activeOptionRecordRef.current !== record) { - return - } - writes.current.count -= 1 - writes.current.readback ||= readback - if (!writes.current.count && writes.current.readback) { - rereadOptions() - } - } - } const permissionOwnSupport = useStructuredAgentSessionOwnPermissionState(target) const permissionOwnSupported = permissionOwnSupport === 'unknown' ? null : permissionOwnSupport === 'supported' + const optionView = useCallback( + ( + state: StructuredAgentSessionOptionState, + seed: Readonly> | undefined, + explicit?: Readonly> + ) => ({ + ...projectRuntimePermission( + structuredAgentSessionOptionView(state, seed, controller.overlay(), explicit), + summary, + fence + ), + permissionOwnSupported + }), + [controller, summary, fence, permissionOwnSupported] + ) const takesSendOptions = useStructuredAgentSessionHostCapability( target, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY @@ -288,26 +298,17 @@ export function useStructuredAgentSessionOptionState(args: { return { // What the picker shows and a send carries. - optionView: ( - ...args: Parameters - ): StructuredAgentSessionOptionState => { - const view = { - ...projectRuntimePermission(structuredAgentSessionOptionView(...args), summary, fence), - permissionOwnSupported - } - return structuredOptionPickIsPending(optionPicks.current, 'model') - ? { ...view, pendingId: 'model' } - : view - }, + optionView, + controller, + takesSelection, takesSendOptions, conversationSupport, + rereadOptions, receiveCatalog, - beginOptionWrite, isCurrentContext, optionState, optionStateRef, activeOptionRecordRef, - optionPicks, updateOptionState } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx index 96e27dba716..c42d0430fa2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as HostCapability from '@/runtime/structured-agent-session-host-capability' +import type * as LaunchOptions from '@/lib/structured-agent-session-launch-options' const mocks = vi.hoisted(() => ({ call: vi.fn(), coherent: false })) @@ -19,7 +20,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('./native-chat-session-option-settings-write', () => ({ enqueueSessionOptionSettingsWrite: vi.fn() })) -vi.mock('@/lib/structured-agent-session-launch-options', () => ({ +vi.mock('@/lib/structured-agent-session-launch-options', async (original) => ({ + ...(await original()), holdStructuredAgentSessionLaunchOption: vi.fn(), getStructuredAgentSessionLaunchSelection: () => null })) @@ -116,7 +118,6 @@ function renderNewChat(args: { mutate: vi.fn(), launch: { kind: 'new', - heldOptions: {}, worktree: 'id:wt-1', ...(args.explicitOptions ? { explicitOptions: args.explicitOptions } : {}), ...(args.seedOptions ? { seedOptions: args.seedOptions } : {}) @@ -360,11 +361,13 @@ it('refuses typed literals while the current fence awaits options, then accepts const pending = deferred() let reads = 0 mocks.call.mockImplementation((_target: unknown, method: string) => - method === 'agentSession.options' - ? ++reads === 1 - ? Promise.resolve({ models: [], current: {} }) - : pending.promise - : Promise.resolve({ origin: 'unknown' }) + method === 'agentSession.setOption' + ? Promise.resolve({ ok: true, value: { options: { model: 'literal' } } }) + : method === 'agentSession.options' + ? ++reads === 1 + ? Promise.resolve({ models: [], current: {} }) + : pending.promise + : Promise.resolve({ origin: 'unknown' }) ) const mutate = vi.fn().mockResolvedValue({ status: 'accepted', @@ -394,10 +397,20 @@ it('refuses typed literals while the current fence awaits options, then accepts expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(false) ) expect(mutate).not.toHaveBeenCalled() + expect(mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.setOption')).toEqual([]) await act(async () => pending.resolve({ models: [], current: {} })) await act(async () => expect(await view.result.current.setStructuredOption('model', 'literal')).toBe(true) ) - expect(mutate).toHaveBeenCalledTimes(1) + expect(mocks.call).toHaveBeenCalledWith( + LOCAL_TARGET, + 'agentSession.setOption', + { + envelope: expect.objectContaining({ sessionId: 'locked-chat', expectedRuntimeFence: 2 }), + key: 'model', + value: 'literal' + }, + {} + ) view.unmount() }) 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 aa872aa6eb2..8895e2b76c4 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 @@ -85,13 +85,12 @@ function pill(snapshot: readonly SessionOptionDescriptor[]): Pill { const LOCAL = { kind: 'local' } as const const NOT_STARTED: { running: { fence: number } | null } = { running: null } -const NO_HELD = {} const NO_MUTATE: StructuredAgentSessionMutate = vi.fn() function renderChat(agent: string, sessionId: string, worktree: string) { const frames: Pill[] = [] // Stable across renders, as the pane's own props are: a new object would re-run every read. - const launch = { kind: 'new' as const, heldOptions: NO_HELD, worktree } + const launch = { kind: 'new' as const, worktree } const view = renderHook( ({ running }: { running: { fence: number } | null }) => { const options = useStructuredAgentSessionOptions({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index 754ec0aed32..d7fce96a594 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -1,3 +1,8 @@ +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' +import type { + AgentSessionMutationResult, + AgentSessionOptionResult +} from '../../../../shared/agent-session-wire' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY, @@ -7,9 +12,9 @@ import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option- // @vitest-environment happy-dom import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ call: vi.fn() })) +const mocks = vi.hoisted(() => ({ call: vi.fn(), write: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -22,22 +27,40 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ enqueueSessionOptionSettingsWrite: vi.fn() })) -vi.mock('@/lib/structured-agent-session-launch-options', () => ({ - holdStructuredAgentSessionLaunchOption: vi.fn(), - getStructuredAgentSessionLaunchSelection: () => null -})) - import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' const mutate: StructuredAgentSessionMutate = vi.fn(async () => null) +afterEach(() => { + forgetStructuredSessionSelection('session-1', LOCAL_TARGET) + setLocalRuntimeCapabilitiesForTests(null) +}) +beforeEach(() => { + mocks.write.mockReset().mockImplementation(async ({ key, value }) => acceptedReply(key, value)) +}) const LOCAL_TARGET = { kind: 'local' } as const +function acceptedReply( + key: string, + value: string +): AgentSessionMutationResult { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'permission-test', sequence: 1 }, + value: { key, value, options: { [key]: value } } + } +} + function optionsAnswering(...modes: (string | undefined)[]): void { let read = 0 - mocks.call.mockImplementation((_target: unknown, method: string) => { + mocks.call.mockImplementation((_target: unknown, method: string, argumentsForWrite: unknown) => { + if (method === 'agentSession.setOption') { + return mocks.write(argumentsForWrite) + } if (method !== 'agentSession.options') { return new Promise(() => {}) } @@ -150,25 +173,34 @@ describe('a permission pick on this device', () => { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY ]) - mocks.call.mockResolvedValue({ - models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }], - current: { model: 'model-a', permissionMode: 'own', reportedPermissionMode: 'full-access' } - }) - vi.mocked(mutate).mockResolvedValue({ options: { permissionMode: 'full-access' } }) + optionsAnswering('own') + const read = mocks.call.getMockImplementation()! + mocks.call.mockImplementation((target, method, params) => + method === 'agentSession.options' + ? Promise.resolve({ + models: [{ id: 'model-a', label: 'Model A', isDefault: true, efforts: [] }], + current: { + model: 'model-a', + permissionMode: 'own', + reportedPermissionMode: 'full-access' + } + }) + : read(target, method, params) + ) vi.mocked(enqueueSessionOptionSettingsWrite).mockClear() const { result, unmount } = renderReopened(1) await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access')) await act(async () => { expect(await result.current.setStructuredOption('permissionMode', 'full-access')).toBe(true) }) - expect(mutate).toHaveBeenCalledExactlyOnceWith( - 'agentSession.setOption', - 'agentSession.setOption', - { + expect(mocks.write).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + envelope: expect.objectContaining({ sessionId: 'session-1', expectedRuntimeFence: 1 }), key: 'permissionMode', value: 'full-access' - } + }) ) + expect(mutate).not.toHaveBeenCalled() expect(enqueueSessionOptionSettingsWrite).not.toHaveBeenCalled() unmount() }) @@ -179,16 +211,49 @@ describe('a permission pick on this device', () => { const { result, unmount } = renderReopened(1) await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) expect(await result.current.setStructuredOption('permissionMode', 'own')).toBe(false) - expect(mutate).not.toHaveBeenCalled() + expect(mocks.write).not.toHaveBeenCalled() unmount() setLocalRuntimeCapabilitiesForTests(null) }) + it('dispatches supported Own reset without locking permission or the next model pick', async () => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY + ]) + optionsAnswering('ask') + const { result, unmount } = renderReopened(1) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) + const reply = Promise.withResolvers>() + mocks.write.mockReturnValueOnce(reply.promise) + let picking!: Promise + act(() => { + picking = result.current.setStructuredOption('permissionMode', 'own') + }) + expect(mocks.write).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + key: 'permissionMode', + value: 'own', + envelope: expect.objectContaining({ expectedRuntimeFence: 1 }) + }) + ) + expect(result.current.sendSource.optionStateRef.current.pendingId).toBeNull() + expect(result.current.optionSnapshot.find((row) => row.id === 'model')?.settable).toBe(true) + await act(async () => { + reply.resolve(acceptedReply('permissionMode', 'own')) + await picking + }) + await expect(picking).resolves.toBe(true) + expect(result.current.sendSource.optionStateRef.current.pendingId).toBeNull() + expect(result.current.optionSnapshot.find((row) => row.id === 'model')?.settable).toBe(true) + unmount() + }) + it('shows at once and stays pickable while the write waits behind a start', async () => { optionsAnswering('ask', 'auto') - const first = Promise.withResolvers<{ options: Record }>() - const second = Promise.withResolvers<{ options: Record }>() - vi.mocked(mutate).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const first = Promise.withResolvers>() + const second = Promise.withResolvers>() + mocks.write.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) const { result, unmount } = renderReopened(1) await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) @@ -200,36 +265,39 @@ describe('a permission pick on this device', () => { void result.current.setStructuredOption('permissionMode', 'auto') }) expect(permission(result.current.optionSnapshot)).toBe('auto') - expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2) - await act(async () => second.resolve({ options: { permissionMode: 'auto' } })) - await act(async () => first.resolve({ options: { permissionMode: 'full-access' } })) + expect(mocks.write).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(acceptedReply('permissionMode', 'auto'))) + await act(async () => first.resolve(acceptedReply('permissionMode', 'full-access'))) expect(permission(result.current.optionSnapshot)).toBe('auto') unmount() // A permission commit clears generic pendingId while the model request still runs. let model = 'model-a' - mocks.call.mockImplementation((_target: unknown, method: string) => - Promise.resolve( - method === 'agentSession.options' - ? { - models: ['model-a', 'model-b'].map((id) => ({ - id, - label: id, - isDefault: false, - efforts: [], - supportsAutoMode: true - })), - current: { model, permissionMode: model === 'model-a' ? 'ask' : 'full-access' } - } - : { origin: 'unknown' } - ) + forgetStructuredSessionSelection('session-1', LOCAL_TARGET) + mocks.call.mockImplementation((_target: unknown, method: string, params: unknown) => + method === 'agentSession.setOption' + ? mocks.write(params) + : Promise.resolve( + method === 'agentSession.options' + ? { + models: ['model-a', 'model-b'].map((id) => ({ + id, + label: id, + isDefault: false, + efforts: [], + supportsAutoMode: true + })), + current: { model, permissionMode: model === 'model-a' ? 'ask' : 'full-access' } + } + : { origin: 'unknown' } + ) ) - const modelWrite = Promise.withResolvers<{ options: Record }>() - const modeWrite = Promise.withResolvers<{ options: Record }>() - vi.mocked(mutate) + const modelWrite = Promise.withResolvers>() + const modeWrite = Promise.withResolvers>() + mocks.write .mockReturnValueOnce(modelWrite.promise) .mockReturnValueOnce(modeWrite.promise) - .mockResolvedValue({ options: { permissionMode: 'auto' } }) + .mockResolvedValue(acceptedReply('permissionMode', 'auto')) const claude = renderReopened(1, 'claude') await waitFor(() => expect(permission(claude.result.current.optionSnapshot)).toBe('ask')) const choices = (snapshot: readonly SessionOptionDescriptor[]) => { @@ -253,17 +321,20 @@ describe('a permission pick on this device', () => { const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto') expect(choices(returned.snapshot)).toContain('auto') }) - expect(vi.mocked(mutate)).toHaveBeenCalledTimes(6) + expect(mocks.write).toHaveBeenCalledTimes(6) model = 'model-b' - await act(async () => modeWrite.resolve({ options: { permissionMode: 'full-access' } })) - await act(async () => modelWrite.resolve({ options: { model } })) + await act(async () => modeWrite.resolve(acceptedReply('permissionMode', 'full-access'))) + await act(async () => modelWrite.resolve(acceptedReply('model', model))) expect(choices(claude.result.current.optionSnapshot)).toContain('auto') claude.unmount() }) it("shows the host's mode again when the write is refused", async () => { optionsAnswering('ask') - vi.mocked(mutate).mockResolvedValue(null) + mocks.write.mockResolvedValue({ + ok: false, + refusal: { code: 'agent_session_operation_capacity', message: 'Refused' } + }) const { result, unmount } = renderReopened(1) await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx index 27fdf35c602..336aec15e19 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx @@ -111,7 +111,7 @@ function renderNewChat(agent: string) { turnId: null, unloadedTurnRevisions: undefined, mutate: vi.fn(), - launch: { kind: 'new', heldOptions: {}, worktree: 'id:wt-1' } + launch: { kind: 'new', worktree: 'id:wt-1' } }) frames.push(pill(options.optionSnapshot)) return options diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test-fixture.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test-fixture.tsx new file mode 100644 index 00000000000..e04f4554e12 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test-fixture.tsx @@ -0,0 +1,289 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { vi, type Mock } from 'vitest' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type * as LaunchOptions from '@/lib/structured-agent-session-launch-options' +import type * as HostCapability from '@/runtime/structured-agent-session-host-capability' +import type { SelectionOutcome } from '../../../../shared/structured-agent-session-selection-operations' + +type OptionsFixtureMocks = { + call: Mock + enqueue: Mock + pick: Mock + target: RuntimeClientTarget + agent: 'codex' | 'claude' + hold: Mock< + ( + sessionId: string, + id: string, + encoded: string, + picker?: unknown + ) => Promise | null + > + toastError: Mock<(message: string) => void> + coherentCatalog: boolean +} + +const mocks: OptionsFixtureMocks = vi.hoisted(() => { + const selection: { target: RuntimeClientTarget; agent: 'codex' | 'claude' } = { + target: { kind: 'local' }, + agent: 'codex' + } + return { + call: vi.fn(), + enqueue: vi.fn(), + pick: vi.fn(), + ...selection, + hold: vi.fn< + ( + sessionId: string, + id: string, + encoded: string, + picker?: unknown + ) => Promise | null + >(), + toastError: vi.fn<(message: string) => void>(), + coherentCatalog: false + } +}) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + +vi.mock('@/runtime/structured-agent-session-host-capability', async () => { + const actual = await vi.importActual( + '@/runtime/structured-agent-session-host-capability' + ) + return { + ...actual, + useStructuredAgentSessionHostCapability: ( + target: RuntimeClientTarget, + capability: Parameters[1] + ) => { + const supported = actual.useStructuredAgentSessionHostCapability(target, capability) + return ( + (mocks.coherentCatalog && capability === 'agent-session.coherent-model-catalog.v1') || + supported + ) + } + } +}) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), + callStructuredAgentSession: mocks.call +})) + +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: mocks.enqueue +})) + +vi.mock('@/lib/structured-agent-session-launch-options', async () => { + const { structuredSessionSelectionController } = + await import('@/lib/structured-agent-session-selection-controller') + const actual = await vi.importActual( + '@/lib/structured-agent-session-launch-options' + ) + return { + ...actual, + holdStructuredAgentSessionLaunchOption: (...args: Parameters) => { + const reply = mocks.hold(...args) + if (!reply) { + return null + } + const controller = structuredSessionSelectionController(mocks.target, args[0], mocks.agent) + const operation = controller.issue( + args[1], + args[2], + args[1] === 'model' ? args[2] : 'gpt-5.5' + ) + void reply.then((outcome) => controller.settle(operation, outcome)) + return operation.result + }, + getStructuredAgentSessionLaunchSelection: () => null + } +}) + +import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const LOCAL_TARGET = { kind: 'local' } as const +const PAIRED_TARGET = { kind: 'environment', environmentId: 'server-1' } as const + +class FakeRpcCallError extends Error { + constructor(readonly code: string) { + super(code) + } +} + +type Answers = { + options?: () => Promise + modelCatalog?: () => Promise +} + +function answer(answers: Answers): void { + mocks.call.mockImplementation( + async (_target: unknown, method: string, params: { key: string; value: string }) => { + if (method === 'agentSession.setOption') { + const value = await mocks.pick(method, method, { key: params.key, value: params.value }) + return value ? { ok: true, value } : Promise.reject(new Error('Unconfirmed option write')) + } + if (method === 'agentSession.options' && answers.options) { + return answers.options() + } + if (method === 'agentSession.modelCatalog' && answers.modelCatalog) { + return answers.modelCatalog() + } + return new Promise(() => {}) + } + ) +} + +type MutateCall = (...args: unknown[]) => Promise + +function mutateWith(reply: MutateCall): { + mutate: StructuredAgentSessionMutate + calls: ReturnType> +} { + const calls = vi.fn(reply) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each reply is null or the AgentSessionOptionResult shape the hook reads. + return { mutate: calls as unknown as StructuredAgentSessionMutate, calls } +} + +type RenderProps = { + transportEnabled: boolean + fence: number | null + turnId?: string | null + launch?: 'new' | 'resume' + hidden?: boolean + launchSeedOptions?: Record + worktree?: string + agent?: 'claude' | 'codex' + providerStarting?: boolean + paired?: boolean +} + +const PROVISIONAL: RenderProps = { transportEnabled: false, fence: null, launch: 'new' } +// The receipt lands first; attach delivers the fence on a later render. +const PUBLISHED_UNATTACHED: RenderProps = { transportEnabled: true, fence: null, launch: 'new' } +const ATTACHED: RenderProps = { transportEnabled: true, fence: 1, launch: 'new' } +const REOPENED: RenderProps = { transportEnabled: true, fence: 1 } + +function renderOptions(initial: RenderProps, mutate: StructuredAgentSessionMutate) { + mocks.pick.mockImplementation(mutate) + mocks.target = initial.paired ? PAIRED_TARGET : LOCAL_TARGET + mocks.agent = initial.agent ?? 'codex' + return renderHook( + (props: RenderProps) => + useStructuredAgentSessionOptions({ + agent: props.agent ?? 'codex', + sessionId: 'session-1', + target: props.paired ? PAIRED_TARGET : LOCAL_TARGET, + transportEnabled: props.transportEnabled, + isVisible: !props.hidden, + providerVisible: props.transportEnabled && !props.hidden, + ...(props.providerStarting ? { providerStarting: true } : {}), + fence: props.fence, + turnId: props.turnId ?? null, + unloadedTurnRevisions: undefined, + mutate, + ...(props.launch + ? { + launch: { + kind: props.launch, + ...(props.launchSeedOptions ? { seedOptions: props.launchSeedOptions } : {}), + ...(props.worktree ? { worktree: props.worktree } : {}) + } + } + : {}) + }), + { initialProps: initial } + ) +} + +function descriptor(snapshot: readonly SessionOptionDescriptor[], id: string) { + return snapshot.find((entry) => entry.id === id) +} + +function currentValue(snapshot: readonly SessionOptionDescriptor[], id: string) { + const entry = descriptor(snapshot, id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null +} + +function modelChoiceCount(snapshot: readonly SessionOptionDescriptor[]): number { + const model = descriptor(snapshot, 'model') + return model?.kind.type === 'select' ? model.kind.choices.length : 0 +} + +function setOptionCalls(calls: ReturnType>): unknown[] { + return calls.mock.calls + .filter(([method]) => method === 'agentSession.setOption') + .map(([, , fields]) => fields) +} + +const HOST_CATALOG = { + origin: 'live-session', + models: [ + { + id: 'gpt-hosted', + label: 'GPT Hosted', + isDefault: true, + efforts: [{ value: 'high', label: 'High' }] + } + ], + fetchedAt: 1_000 +} + +const LIVE_OPTIONS = { + models: [ + { id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] } + ], + current: { model: 'gpt-5.5', confirmed: ['model'] } +} + +const SAVED_LIST = { + origin: 'probe', + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] } + ], + fetchedAt: 1_000 +} + +const SEED = { model: 'gpt-5.5' } +const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +export { + mocks, + LOCAL_TARGET, + PAIRED_TARGET, + FakeRpcCallError, + answer, + mutateWith, + PROVISIONAL, + PUBLISHED_UNATTACHED, + ATTACHED, + REOPENED, + renderOptions, + descriptor, + currentValue, + modelChoiceCount, + setOptionCalls, + HOST_CATALOG, + LIVE_OPTIONS, + SAVED_LIST, + SEED, + tick +} + +export type { Answers, MutateCall, RenderProps } 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 7e253662b62..309db5fbd0d 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 @@ -1,196 +1,101 @@ // @vitest-environment happy-dom +import { + mocks, + LOCAL_TARGET, + PAIRED_TARGET, + FakeRpcCallError, + answer, + mutateWith, + PROVISIONAL, + PUBLISHED_UNATTACHED, + ATTACHED, + REOPENED, + renderOptions, + descriptor, + currentValue, + modelChoiceCount, + setOptionCalls, + HOST_CATALOG, + LIVE_OPTIONS, + SAVED_LIST, + SEED, + tick +} from './use-structured-agent-session-options.test-fixture' +import { act, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - call: vi.fn(), - enqueue: vi.fn(), - hold: vi.fn< - (sessionId: string, id: string, encoded: string, picker?: unknown) => Promise | null - >(), - toastError: vi.fn<(message: string) => void>() -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })), - callStructuredAgentSession: mocks.call -})) - -vi.mock('./native-chat-session-option-settings-write', () => ({ - enqueueSessionOptionSettingsWrite: mocks.enqueue -})) - -vi.mock('@/lib/structured-agent-session-launch-options', () => ({ - holdStructuredAgentSessionLaunchOption: mocks.hold, - getStructuredAgentSessionLaunchSelection: () => null -})) - -import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' -import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' +import { + structuredAgentSessionSendSelection, + structuredAgentSessionMessageSelection +} from '../../../../shared/structured-agent-session-option-view' +import { + forgetStructuredSessionSelection, + structuredSessionSelectionController +} from '@/lib/structured-agent-session-selection-controller' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' - -const LOCAL_TARGET = { kind: 'local' } as const -const PAIRED_TARGET = { kind: 'environment', environmentId: 'server-1' } as const - -class FakeRpcCallError extends Error { - constructor(readonly code: string) { - super(code) - } -} - -type Answers = { - options?: () => Promise - modelCatalog?: () => Promise -} - -function answer(answers: Answers): void { - mocks.call.mockImplementation((_target: unknown, method: string) => { - if (method === 'agentSession.options' && answers.options) { - return answers.options() - } - if (method === 'agentSession.modelCatalog' && answers.modelCatalog) { - return answers.modelCatalog() - } - return new Promise(() => {}) - }) -} - -type MutateCall = (...args: unknown[]) => Promise - -function mutateWith(reply: MutateCall): { - mutate: StructuredAgentSessionMutate - calls: ReturnType> -} { - const calls = vi.fn(reply) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each reply is null or the AgentSessionOptionResult shape the hook reads. - return { mutate: calls as unknown as StructuredAgentSessionMutate, calls } -} - -type RenderProps = { - transportEnabled: boolean - fence: number | null - turnId?: string | null - launch?: 'new' | 'resume' - hidden?: boolean - launchSeedOptions?: Record - heldOptions?: Record - worktree?: string - agent?: 'claude' | 'codex' - providerStarting?: boolean - paired?: boolean -} - -const PROVISIONAL: RenderProps = { transportEnabled: false, fence: null, launch: 'new' } -// The receipt lands first; attach delivers the fence on a later render. -const PUBLISHED_UNATTACHED: RenderProps = { transportEnabled: true, fence: null, launch: 'new' } -const ATTACHED: RenderProps = { transportEnabled: true, fence: 1, launch: 'new' } -const REOPENED: RenderProps = { transportEnabled: true, fence: 1 } - -function renderOptions(initial: RenderProps, mutate: StructuredAgentSessionMutate) { - return renderHook( - (props: RenderProps) => - useStructuredAgentSessionOptions({ - agent: props.agent ?? 'codex', - sessionId: 'session-1', - target: props.paired ? PAIRED_TARGET : LOCAL_TARGET, - transportEnabled: props.transportEnabled, - isVisible: !props.hidden, - providerVisible: props.transportEnabled && !props.hidden, - ...(props.providerStarting ? { providerStarting: true } : {}), - fence: props.fence, - turnId: props.turnId ?? null, - unloadedTurnRevisions: undefined, - mutate, - ...(props.launch - ? { - launch: { - kind: props.launch, - ...(props.launchSeedOptions ? { seedOptions: props.launchSeedOptions } : {}), - heldOptions: props.heldOptions ?? {}, - ...(props.worktree ? { worktree: props.worktree } : {}) - } - } - : {}) - }), - { initialProps: initial } - ) -} - -function descriptor(snapshot: readonly SessionOptionDescriptor[], id: string) { - return snapshot.find((entry) => entry.id === id) -} - -function currentValue(snapshot: readonly SessionOptionDescriptor[], id: string) { - const entry = descriptor(snapshot, id) - return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null -} - -function modelChoiceCount(snapshot: readonly SessionOptionDescriptor[]): number { - const model = descriptor(snapshot, 'model') - return model?.kind.type === 'select' ? model.kind.choices.length : 0 -} - -function setOptionCalls(calls: ReturnType>): unknown[] { - return calls.mock.calls - .filter(([method]) => method === 'agentSession.setOption') - .map(([, , fields]) => fields) -} - -const HOST_CATALOG = { - origin: 'live-session', - models: [ - { - id: 'gpt-hosted', - label: 'GPT Hosted', - isDefault: true, - efforts: [{ value: 'high', label: 'High' }] - } - ], - fetchedAt: 1_000 -} - -const LIVE_OPTIONS = { - models: [ - { id: 'gpt-5.5', label: 'GPT-5.5', isDefault: true, efforts: [] }, - { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] } - ], - current: { model: 'gpt-5.5', confirmed: ['model'] } -} - -const SAVED_LIST = { - origin: 'probe', - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - isDefault: true, - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - }, - { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: [] } - ], - fetchedAt: 1_000 -} - -const SEED = { model: 'gpt-5.5' } -const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) - describe('useStructuredAgentSessionOptions', () => { beforeEach(() => { + forgetStructuredSessionSelection('session-1') mocks.call.mockReset() mocks.enqueue.mockReset() mocks.hold.mockReset() mocks.toastError.mockReset() + mocks.coherentCatalog = false resetHostModelCatalogSnapshotsForTests() }) + it('retains a queried seed-catalog base through same-session attach and later metadata', async () => { + mocks.coherentCatalog = true + let landCatalog = (_value: unknown): void => {} + const catalog = new Promise((resolve) => (landCatalog = resolve)) + let reads = 0 + answer({ + modelCatalog: () => catalog, + options: () => + ++reads === 1 + ? Promise.resolve({ + ...LIVE_OPTIONS, + current: { model: 'gpt-5.5', defaulted: ['model'], acceptedInputSources: {} } + }) + : new Promise(() => {}) + }) + const stale = { model: 'old-unlisted', effort: 'high' } + const props = { ...ATTACHED, launchSeedOptions: stale } + const { result, rerender, unmount } = renderOptions(props, mutateWith(async () => null).mutate) + const capture = () => { + const { optionStateRef, optionView } = result.current.sendSource + return structuredAgentSessionMessageSelection( + optionView(optionStateRef.current, stale, stale) + ) + } + await waitFor(() => + expect(result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true) + ) + expect(result.current.sendSource.optionStateRef.current.catalogSource).toBe('seed') + expect(capture()).toEqual({}) + rerender({ ...props, fence: 2 }) + expect(capture()).toEqual({}) + expect(result.current.sendSource.optionStateRef.current.optionsAnswerPresent).toBe(true) + await act(async () => { + landCatalog({ ...SAVED_LIST, listingNamesConfiguredModel: true }) + await tick() + }) + expect(capture()).toEqual({}) + await act(async () => { + structuredSessionSelectionController(LOCAL_TARGET, 'session-1', 'codex').issue( + 'effort', + 'low', + 'gpt-5.5' + ) + }) + expect(capture()).toEqual({ effort: 'low' }) + rerender({ ...props, fence: 3 }) + expect(capture()).toEqual({ effort: 'low' }) + unmount() + forgetStructuredSessionSelection('session-1') + }) + it('upgrades the seed with the host catalog while the live read is still pending', async () => { answer({ modelCatalog: () => Promise.resolve(HOST_CATALOG) }) const { result, unmount } = renderOptions(ATTACHED, mutateWith(async () => null).mutate) @@ -318,8 +223,14 @@ describe('useStructuredAgentSessionOptions', () => { accepted = await result.current.setStructuredOption('model', 'gpt-5.6-luna') }) expect(accepted).toBe(true) - expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-5.6-luna') - rerender({ ...PROVISIONAL, launchSeedOptions: SEED, heldOptions: { model: 'gpt-5.6-luna' } }) + expect(mocks.hold).toHaveBeenCalledWith( + 'session-1', + 'model', + 'gpt-5.6-luna', + expect.anything() + ) + // The launch owns the pick; the view renders what it holds. + rerender({ ...PROVISIONAL, launchSeedOptions: SEED }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('gpt-5.6-luna') expect(descriptor(result.current.optionSnapshot, 'model')?.valueSource).toBe('dispatched') expect(calls).not.toHaveBeenCalled() @@ -584,6 +495,7 @@ describe('useStructuredAgentSessionOptions', () => { }) describe("a paired server's new chat", () => { + beforeEach(() => replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 1 }])) it("shows the server's seed once its list answers and remembers a pick on the server under its model", async () => { answer({ modelCatalog: () => Promise.resolve(SAVED_LIST) }) mocks.hold.mockResolvedValue({ kind: 'accepted', options: { effort: 'high' } }) @@ -600,11 +512,15 @@ describe('useStructuredAgentSessionOptions', () => { await result.current.setStructuredOption('effort', 'high') }) await waitFor(() => - expect(mocks.enqueue).toHaveBeenCalledWith(PAIRED_TARGET, { - type: 'apply-picks', - agent: 'codex', - picks: [{ modelId: 'gpt-5.5', optionId: 'effort', value: 'high' }] - }) + expect(mocks.enqueue).toHaveBeenCalledWith( + PAIRED_TARGET, + { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-5.5', optionId: 'effort', value: 'high' }] + }, + { expectedEnvironmentPairingRevision: 1 } + ) ) unmount() }) @@ -624,7 +540,7 @@ describe('useStructuredAgentSessionOptions', () => { await act(async () => { await result.current.setStructuredOption('model', 'gpt-hosted') }) - expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted') + expect(mocks.hold).toHaveBeenCalledWith('session-1', 'model', 'gpt-hosted', expect.anything()) unmount() }) @@ -686,7 +602,7 @@ describe('useStructuredAgentSessionOptions', () => { ) const { optionStateRef, optionView } = result.current.sendSource expect( - structuredAgentSessionSendSelection(optionView(optionStateRef.current, undefined, {})) + structuredAgentSessionSendSelection(optionView(optionStateRef.current, undefined)) ).toEqual({ model: 'opus', effort: 'high' }) unmount() } @@ -732,8 +648,13 @@ describe('useStructuredAgentSessionOptions', () => { current: { ...pick.current, confirmed: ['model'] } } let reads = 0 - // The read after the pick never lands, so only the reply speaks. - answer({ options: () => (++reads === 1 ? Promise.resolve(live) : new Promise(() => {})) }) + // The authoritative read supplies model-dependent normalization; replies settle operations. + answer({ + options: async () => + ++reads === 1 + ? live + : { ...live, current: { ...live.current, model: 'second', ...pick.reply } } + }) const { mutate } = mutateWith(async () => ({ key: 'model', value: 'second', 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 6e8685a03e3..5ab4bdae4b2 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,55 +1,33 @@ import type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract' import { toast } from 'sonner' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' -import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat/input/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { canSetStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionSnapshot, - type StructuredAgentSessionOptionState + structuredAgentSessionOptionPicks } from '../../../../shared/structured-agent-session-options' -import { useRememberSessionOptionPicks } from './use-structured-agent-session-option-picks' -import { - beginStructuredOptionPick, - isLatestStructuredOptionPick, - settleStructuredOptionPick, - structuredOptionPickCommit, - structuredOptionPickBlockingId -} from '../../../../shared/structured-agent-session-option-pick-book' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' import { - getStructuredAgentSessionLaunchSelection, - holdStructuredAgentSessionLaunchOption, - type StructuredLaunchOptionOutcome + flushStructuredSessionSelectionOptions, + holdStructuredAgentSessionLaunchOption } from '@/lib/structured-agent-session-launch-options' export type { StructuredAgentSessionOptionsArgs } from './structured-agent-session-options-contract' -const NO_HELD_OPTIONS: Readonly> = {} - export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOptionsArgs) { - const { - agent, - fence, - launch, - mutate, - providerVisible, - sessionId, - target, - transportEnabled, - turnId - } = args + const { agent, fence, launch, providerVisible, sessionId, target, transportEnabled, turnId } = + args + const capturePresentation = args.capturePresentation const providerStarting = args.providerStarting ?? false const newLaunch = launch?.kind === 'new' const launchSeedOptions = launch?.seedOptions - const held = launch?.heldOptions ?? NO_HELD_OPTIONS const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const { optionView, @@ -57,10 +35,11 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt optionState, optionStateRef, activeOptionRecordRef, - optionPicks, + controller, + takesSelection, updateOptionState, + rereadOptions, receiveCatalog, - beginOptionWrite, isCurrentContext, conversationSupport } = useStructuredAgentSessionOptionState({ @@ -106,95 +85,6 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt receiveCatalog }) - const pendingView = useCallback( - (state: StructuredAgentSessionOptionState) => - optionView(state, launchSeedOptions, held, launch?.explicitOptions), - [held, launch?.explicitOptions, launchSeedOptions, optionView] - ) - - const rememberOptionPicks = useRememberSessionOptionPicks(target, agent) - const sendStructuredOption = useCallback( - async (id: string, encoded: string): Promise => { - const currentState = optionStateRef.current - const targetRecord = currentState.record - const finishWrite = beginOptionWrite(id) - // Show record-only picks immediately while their writes wait behind provider startup. - const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - const pick = beginStructuredOptionPick(optionPicks.current, id, encoded, shownAtOnce) - const sameRecord = (): boolean => - isCurrentContext() && activeOptionRecordRef.current === targetRecord - const isCurrent = (): boolean => - sameRecord() && isLatestStructuredOptionPick(optionPicks.current, pick) - updateOptionState((current) => - shownAtOnce - ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) - : { ...current, pendingId: id } - ) - let result: AgentSessionOptionResult | null = null - try { - result = await mutate( - 'agentSession.setOption', - 'agentSession.setOption', - { key: id, value: encoded } - ) - const committed = - result && sameRecord() - ? structuredOptionPickCommit(optionPicks.current, pick, result.options) - : null - if (committed) { - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) - : current - ) - // The launch seed names the model an effort-only pick was made under. - rememberOptionPicks( - optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), - committed - ) - } - return Boolean(result) - } finally { - if (sameRecord()) { - settleStructuredOptionPick(optionPicks.current, pick) - } - if (isCurrent()) { - updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } - : current - ) - } - finishWrite(isCurrent() && Boolean(result || shownAtOnce)) - } - }, - [ - activeOptionRecordRef, - beginOptionWrite, - isCurrentContext, - launchSeedOptions, - mutate, - optionPicks, - optionStateRef, - optionView, - providerStarting, - rememberOptionPicks, - updateOptionState - ] - ) - const settleLaunchOptionPick = useCallback( - (outcome: StructuredLaunchOptionOutcome) => { - if (outcome.kind === 'refused') { - toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) - } else if (outcome.kind === 'accepted') { - rememberOptionPicks( - optionView(optionStateRef.current, launchSeedOptions, NO_HELD_OPTIONS), - outcome.options - ) - } - }, - [launchSeedOptions, optionStateRef, optionView, rememberOptionPicks] - ) const interactionReadyRef = useRef(acceptsPicks) const lockSnapshotRef = useRef( (snapshot: ReturnType) => snapshot @@ -206,24 +96,33 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt lockSnapshotRef.current = lockSnapshot }) const optionSnapshot = lockSnapshot( - structuredAgentSessionOptionSnapshot(pendingView(optionState)) + structuredAgentSessionOptionSnapshot( + optionView(optionState, launchSeedOptions, launch?.explicitOptions) + ) ) const setStructuredOption = useCallback( async (id: string, value: string | boolean): Promise => { - const view = pendingView(optionStateRef.current) + const view = optionView(optionStateRef.current, launchSeedOptions, launch?.explicitOptions) + const pickState = { + ...view, + pendingId: + providerStarting || + id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || + !controller.isPending(id) + ? null + : id + } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && value === 'own' && !view.permissionOwnSupported) || !isCurrentContext() || + !acceptsPicks || !optionCatalog || encoded === null || !canSetStructuredAgentSessionOption( - { - ...view, - pendingId: structuredOptionPickBlockingId(optionPicks.current, id, providerStarting) - }, + pickState, id, value, lockSnapshotRef.current(structuredAgentSessionOptionSnapshot(view)), @@ -233,41 +132,69 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt return false } if (!transportEnabled || fence === null) { - // No fence yet: the launch holds it and applies it before anything else is sent. - const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) - void applied?.then(settleLaunchOptionPick) + const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded, view) + void applied?.then((outcome) => { + if (outcome.kind === 'refused' && outcome.failure) { + toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) + } + rereadOptions() + }) return applied !== null } - return sendStructuredOption(id, encoded) + const context = structuredAgentSessionOptionPicks(view, { [id]: encoded })[0]?.modelId ?? null + const presents = capturePresentation?.() + const operation = controller.issue(id, encoded, context) + updateOptionState((current) => ({ + ...current, + pendingId: providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID ? null : id + })) + void flushStructuredSessionSelectionOptions(target, sessionId, fence, controller) + const outcome = await operation.result + updateOptionState((current) => ({ + ...current, + pendingId: controller.pendingId() + })) + if ( + (outcome.kind === 'refused' || outcome.kind === 'unknown') && + outcome.failure && + (presents?.() ?? true) + ) { + toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) + } + rereadOptions() + return outcome.kind !== 'refused' }, [ + controller, + capturePresentation, + acceptsPicks, fence, + launch?.explicitOptions, + launchSeedOptions, + target, optionCatalog, isCurrentContext, optionStateRef, - optionPicks, - pendingView, + optionView, providerStarting, - sendStructuredOption, + rereadOptions, sessionId, - settleLaunchOptionPick, - transportEnabled + transportEnabled, + updateOptionState ] ) const setOption = useCallback( async (id: string, value: string | boolean) => { await setStructuredOption(id, value) - // Read held picks directly; an accepted launch pick may precede this render’s props. - const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held return { snapshot: lockSnapshotRef.current( structuredAgentSessionOptionSnapshot( - optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) + optionView(optionStateRef.current, launchSeedOptions, launch?.explicitOptions) ) ) } }, - [launchSeedOptions, optionStateRef, optionView, sessionId, setStructuredOption] + [launch?.explicitOptions, launchSeedOptions, optionStateRef, optionView, setStructuredOption] ) const optionSurface = useMemo( () => ({ @@ -297,7 +224,7 @@ export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOpt optionSnapshot, optionSurface, setStructuredOption, - sendSource: { optionStateRef, optionPicks, optionView, takesSendOptions }, + sendSource: { optionStateRef, optionView, takesSendOptions, takesSelection }, unavailable: hostCatalog.unavailable } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 2daa1acb08d..1a20f792406 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -139,7 +139,7 @@ describe('useStructuredAgentSession provisional launch gate', () => { agent: 'codex', isVisible: true, transportEnabled: false, - launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} } + launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' } } }) ) @@ -180,7 +180,7 @@ describe('useStructuredAgentSession provisional launch gate', () => { agent: 'grok', isVisible: true, transportEnabled: false, - launch: { kind: 'new', heldOptions: {} } + launch: { kind: 'new' } }) ) expect(render().result.current.canStop).toBe(false) @@ -236,9 +236,14 @@ describe('useStructuredAgentSession provisional launch gate', () => { rerender({ transportEnabled: true }) await waitFor(() => - expect(mocks.call).toHaveBeenCalledWith(LOCAL_TARGET, 'agentSession.options', { - sessionId: 'session-1' - }) + expect(mocks.call).toHaveBeenCalledWith( + LOCAL_TARGET, + 'agentSession.options', + { + sessionId: 'session-1' + }, + {} + ) ) expect(methodsCalled().filter((method) => method === 'agentSession.options')).toHaveLength(1) expect(mocks.hold).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: true })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx index 6e9e1b9a4e6..3013f59dfe7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx @@ -10,8 +10,10 @@ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ import { CLAUDE_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-claude-codex' import { applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' +import { projectRuntimePermission } from '../../../../shared/structured-agent-session-permission-option' import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options' @@ -20,7 +22,8 @@ const levels = (...values: string[]) => values.map((value) => ({ value, label: v function sendOptionsFor( takesSendOptions: boolean, pending: Record, - seedOptions: Record = { model: 'sonnet', effort: 'high' } + seedOptions: Record = { model: 'sonnet', effort: 'high' }, + takesSelection = false ) { const optionStateRef = { current: applyStructuredAgentSessionModelCatalog( @@ -29,9 +32,16 @@ function sendOptionsFor( { origin: 'probe', fetchedAt: 1, + fastModeSupport: { supported: true }, models: [ { id: 'opus[1m]', label: 'Opus', isDefault: false, efforts: levels('low', 'xhigh') }, - { id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: levels('low', 'high') } + { + id: 'sonnet', + label: 'Sonnet', + isDefault: true, + efforts: levels('low', 'high'), + supportsFastMode: true + } ] }, { newLaunch: true } @@ -40,11 +50,18 @@ function sendOptionsFor( const { result } = renderHook(() => useStructuredAgentSessionSendOptions( 'session-1', - { kind: 'new', seedOptions, heldOptions: {} }, + { kind: 'new', seedOptions }, { optionStateRef, - optionPicks: { current: { pending, generation: 0 } }, - optionView: structuredAgentSessionOptionView, + optionView: (state, seed) => ({ + ...projectRuntimePermission( + structuredAgentSessionOptionView(state, seed, pending), + undefined, + null + ), + permissionOwnSupported: null + }), + takesSelection, takesSendOptions } ) @@ -55,21 +72,84 @@ function sendOptionsFor( describe('what each composer send carries', () => { it('carries the shown model and effort to a host that takes them, on every send', () => { const sendOptions = sendOptionsFor(true, {}) - expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' }) - expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' }) + expect(sendOptions()).toEqual({ options: { model: 'sonnet', effort: 'high' } }) + expect(sendOptions()).toEqual({ options: { model: 'sonnet', effort: 'high' } }) }) it('carries a pick still on its way, resolved as the picker shows it', () => { expect(sendOptionsFor(true, { model: 'opus[1m]', effort: 'low' })()).toMatchObject({ - model: 'opus[1m]', - effort: 'low' + options: { model: 'opus[1m]', effort: 'low' } }) expect(sendOptionsFor(true, { model: 'claude-retired' })()).toMatchObject({ - model: 'claude-retired' + options: { model: 'claude-retired' } + }) + }) + + it('captures the same full shown tuple on a capable host, including explicit Fast Off', () => { + expect(sendOptionsFor(false, { fastMode: 'false' }, undefined, true)()).toEqual({ + selection: { model: 'sonnet', effort: 'high', fastMode: 'false' } }) }) it('carries nothing to an older host', () => { - expect(sendOptionsFor(false, {})()).toBeUndefined() + expect(sendOptionsFor(false, {})()).toEqual({}) }) }) + +it('desktop capture freezes only accepted echoed inputs; empty origin differs from legacy absence', () => { + const state = createStructuredAgentSessionOptionState('claude', CLAUDE_SESSION_OPTION_CATALOG) + const answer = { + models: [ + { + id: 'opus[1m]', + label: 'Opus', + isDefault: false, + efforts: levels('high'), + supportsFastMode: true + } + ], + fastModeSupport: { supported: true }, + current: { + model: 'opus[1m]', + effort: 'high', + fastMode: false, + confirmed: ['model', 'effort'], + acceptedInputSources: { model: 'saved' as const, fastMode: 'policy' as const } + } + } + const source = { + optionStateRef: { + current: applyStructuredAgentSessionOptions(state, CLAUDE_SESSION_OPTION_CATALOG, answer) + }, + optionView: (current: typeof state) => ({ + ...projectRuntimePermission( + structuredAgentSessionOptionView(current, undefined, {}), + undefined, + null + ), + permissionOwnSupported: null + }), + takesSelection: true, + takesSendOptions: false + } + const { result } = renderHook(() => + useStructuredAgentSessionSendOptions('session-2', undefined, source) + ) + const captured = result.current() + expect(captured).toEqual({ selection: { model: 'opus[1m]', fastMode: 'false' } }) + source.optionStateRef.current = applyStructuredAgentSessionOptions( + state, + CLAUDE_SESSION_OPTION_CATALOG, + { ...answer, current: { ...answer.current, acceptedInputSources: {} } } + ) + expect(result.current()).toEqual({ selection: {} }) + source.optionStateRef.current = applyStructuredAgentSessionOptions( + state, + CLAUDE_SESSION_OPTION_CATALOG, + { ...answer, current: { ...answer.current, acceptedInputSources: undefined } } + ) + expect(result.current()).toEqual({ + selection: { model: 'opus[1m]', effort: 'high', fastMode: 'false' } + }) + expect(captured).toEqual({ selection: { model: 'opus[1m]', fastMode: 'false' } }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts index cdee8c3c0f7..2e203c6e9a6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts @@ -1,6 +1,9 @@ -import { structuredLaunchRequestedOptions } from '../../../../shared/native-chat/input/native-chat-session-option-defaults' import { useCallback } from 'react' -import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' +import type { StructuredAgentSessionSendControl } from '../../../../shared/structured-agent-session-send-mutation' +import { + structuredAgentSessionMessageSelection, + structuredAgentSessionSendSelection +} from '../../../../shared/structured-agent-session-option-view' import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' @@ -11,29 +14,24 @@ export function useStructuredAgentSessionSendOptions( launch: StructuredAgentSessionLaunchView | undefined, source: Pick< ReturnType, - 'optionStateRef' | 'optionPicks' | 'optionView' | 'takesSendOptions' + 'optionStateRef' | 'optionView' | 'takesSendOptions' | 'takesSelection' > -): () => Readonly> | undefined { - const { optionStateRef, optionView, optionPicks, takesSendOptions } = source +): () => Pick { + const { optionStateRef, optionView, takesSendOptions, takesSelection } = source return useCallback(() => { - if (!takesSendOptions) { - return undefined + if (!takesSendOptions && !takesSelection) { + return {} } // Read held picks directly because launch acceptance can precede this render’s props. const selection = getStructuredAgentSessionLaunchSelection(sessionId) - const requested = structuredLaunchRequestedOptions({ - seed: selection?.seed ?? launch?.seedOptions, - explicit: selection?.explicit ?? launch?.explicitOptions, - held: { ...(selection?.held ?? launch?.heldOptions), ...optionPicks.current.pending } - }) - if (requested?.model !== undefined) { - return requested - } - // A pick on its way resolves as the picker shows it, keeping the model it names. - const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, { - ...(selection?.held ?? launch?.heldOptions), - ...optionPicks.current.pending - }) - return structuredAgentSessionSendSelection(view) - }, [launch, optionStateRef, optionView, optionPicks, sessionId, takesSendOptions]) + // A pick on its way resolves as the picker shows it. + const view = optionView( + optionStateRef.current, + selection?.seed ?? launch?.seedOptions, + selection?.explicit ?? launch?.explicitOptions + ) + return takesSelection + ? { selection: structuredAgentSessionMessageSelection(view) } + : { options: structuredAgentSessionSendSelection(view) } + }, [launch, optionStateRef, optionView, sessionId, takesSendOptions, takesSelection]) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index e9e257e0c42..e05e5c9ce03 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts @@ -1,3 +1,7 @@ +import type { + StructuredAgentSessionSendControl, + StructuredAgentSessionAttachment +} from '../../../../shared/structured-agent-session-send-mutation' import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability' import { AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import type { StructuredAgentSessionSendRequest } from './structured-agent-session-send-slot' @@ -6,7 +10,6 @@ import type { AgentJournalCursor, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionQueueRequest, @@ -43,6 +46,7 @@ export function useStructuredAgentSessionSends(args: { /** The chat reads Stopping: a send made now is drawn after the turn being stopped. */ stopping?: boolean /** The composer's model and effort each send carries; undefined to a host that takes none. */ + sendSelection?: () => Pick sendOptions?: () => Readonly> | undefined }) { const { @@ -53,6 +57,7 @@ export function useStructuredAgentSessionSends(args: { queue, queuedMessageIds, sendOptions, + sendSelection, sessionId, stopping, submissions, @@ -111,7 +116,7 @@ export function useStructuredAgentSessionSends(args: { return false } const delivery = structuredAgentSessionQueueRequest(queue) - const options = sendOptions?.() + const captured = sendSelection?.() ?? { options: sendOptions?.() } const sent = sendStructuredAgentSessionMessage({ sessionId, target, @@ -122,7 +127,7 @@ export function useStructuredAgentSessionSends(args: { ? { usageLimitContinuationOfTurnItemId: request.usageLimitContinuationOfTurnItemId } : {}), ...(delivery ? { delivery } : {}), - ...(options ? { options } : {}), + ...captured, ...(takesSkills && request?.skills?.length ? { skills: request.skills } : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) @@ -141,7 +146,17 @@ export function useStructuredAgentSessionSends(args: { ? 'queued' : true }, - [isWorking, queue, queuedMessageIds, sendOptions, sessionId, stopping, takesSkills, target] + [ + isWorking, + queue, + queuedMessageIds, + sendSelection, + sendOptions, + sessionId, + stopping, + takesSkills, + target + ] ) return { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx index d1c51815bc7..762af1d83f7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' import { AGENT_SESSION_REWIND_RECOVERY_CAPABILITY as RECOVERY } from '../../../../shared/protocol-version' @@ -81,6 +81,30 @@ import { nativeChatRewindReturnedUnknownCopy } from './native-chat-rewind-copy' +import { useAppStore } from '@/store' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' + +beforeEach(() => { + useAppStore.getState().setRuntimeEnvironments([ + { + id: 'ssh-host', + name: 'Original host', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'original' + } + ]) +}) +afterEach(() => { + cleanup() + forgetStructuredSessionSelection('session-1') + useAppStore.getState().setRuntimeEnvironments([]) +}) + const LOCAL_TARGET = { kind: 'local' } as const beforeEach(() => { @@ -390,7 +414,12 @@ describe('offering rewind only where the next send settles an in-doubt one', () mocks.remoteCapabilities = new Set() const view = render(remote) await waitFor(() => - expect(mocks.call).toHaveBeenCalledWith(remote, 'agentSession.options', expect.anything()) + expect(mocks.call).toHaveBeenCalledWith( + remote, + 'agentSession.options', + { sessionId: 'session-1' }, + { expectedEnvironmentPairingRevision: 1 } + ) ) await act(async () => {}) expect(view.result.current.rewind.surface).toBeUndefined() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index a46d0a5ce4c..74db048a063 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn(), @@ -79,6 +79,31 @@ function seededByNextLaunch(): Record | undefined { return resolveStructuredLaunchSeedOptions(persisted, 'codex') } +import { useAppStore } from '@/store' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' + +beforeEach(() => { + useAppStore.getState().setRuntimeEnvironments([ + { + id: 'remote-1', + name: 'Original host', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'original' + } + ]) +}) +afterEach(() => { + cleanup() + forgetStructuredSessionSelection('session-1') + forgetStructuredSessionSelection('replacement-pane') + useAppStore.getState().setRuntimeEnvironments([]) +}) + const LOCAL_TARGET = { kind: 'local' } as const const OPTIONS = { @@ -114,6 +139,17 @@ const FAST_OPTIONS = { current: { ...OPTIONS.current, fastMode: false, confirmed: ['fastMode'] } } +function renderOptionsSession() { + return renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) +} + describe('useStructuredAgentSession working state', () => { beforeEach(() => { vi.clearAllMocks() @@ -205,14 +241,7 @@ describe('useStructuredAgentSession options', () => { } }) }) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { @@ -237,24 +266,18 @@ describe('useStructuredAgentSession options', () => { ? Promise.resolve(OPTIONS) : Promise.reject(new Error('provider rejected option')) ) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { - expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) }) // Show one uncertainty notice without raw transport text or an unproved outcome. expect(mocks.toastError).toHaveBeenCalledWith( "Orca couldn't confirm what happened. Check the chat." ) + expect(mocks.enqueueSettingsWrite).not.toHaveBeenCalled() expect(result.current.error).toBeNull() expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ settable: true @@ -287,14 +310,7 @@ describe('useStructuredAgentSession options', () => { } ) }) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { @@ -305,12 +321,13 @@ describe('useStructuredAgentSession options', () => { const mutations = mocks.call.mock.calls.filter( ([, method]) => method === 'agentSession.setOption' ) - expect( - mutations.map( - ([, , params]) => - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId - ) - ).toEqual(['operation-1', 'operation-2']) + const ids = mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + expect(ids).toEqual([expect.any(String), expect.any(String)]) + expect(ids.every((id) => id.length > 0)).toBe(true) + expect(new Set(ids).size).toBe(2) }) it('sends the pick again as a new operation after a pending admission refusal', async () => { @@ -345,14 +362,7 @@ describe('useStructuredAgentSession options', () => { } ) }) - const { result, rerender } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result, rerender } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { @@ -369,12 +379,13 @@ describe('useStructuredAgentSession options', () => { const mutations = mocks.call.mock.calls.filter( ([, method]) => method === 'agentSession.setOption' ) - expect( - mutations.map( - ([, , params]) => - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId - ) - ).toEqual(['operation-1', 'operation-2']) + const ids = mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + expect(ids).toEqual([expect.any(String), expect.any(String)]) + expect(ids.every((id) => id.length > 0)).toBe(true) + expect(new Set(ids).size).toBe(2) expect( mutations.map( ([, , params]) => @@ -383,40 +394,57 @@ describe('useStructuredAgentSession options', () => { ).toEqual([3, 4]) }) - it('reports an option failure across startup while its pane remains current', async () => { - let reject!: (error: Error) => void - const pending = new Promise((_resolve, rejectPromise) => { - reject = rejectPromise - }) - mocks.call.mockImplementation((_target, method) => - method === 'agentSession.options' || method === 'agentSession.hold' - ? Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) - : pending - ) - const { result, rerender } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true + it.each(['same-pane', 'replacement', 'unmounted'] as const)( + 'keeps uncertainty presentation with its issuing pane across startup (%s)', + async (schedule) => { + let reject!: (error: Error) => void + const pending = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise }) - ) - await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) - let setting!: Promise - act(() => { - setting = result.current.setStructuredOption('model', 'gpt-fast') - }) - fence = 4 - rerender() + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' || method === 'agentSession.hold' + ? Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + : pending + ) + const { result, rerender, unmount } = renderHook( + ({ sessionId }) => + useStructuredAgentSession({ + sessionId, + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }), + { initialProps: { sessionId: 'session-1' } } + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + let setting!: Promise + act(() => { + setting = result.current.setStructuredOption('model', 'gpt-fast') + }) + fence = 4 + rerender({ sessionId: schedule === 'replacement' ? 'replacement-pane' : 'session-1' }) + if (schedule === 'unmounted') { + unmount() + } - await act(async () => { - reject(new Error('stale provider failure')) - await setting - }) + await act(async () => { + reject(new Error('stale provider failure')) + expect(await setting).toBe(true) + }) - expect(result.current.error).toBeNull() - expect(mocks.toastError).toHaveBeenCalledOnce() - }) + expect(result.current.error).toBeNull() + expect(mocks.toastError).toHaveBeenCalledTimes(schedule === 'same-pane' ? 1 : 0) + if (schedule === 'same-pane') { + expect(mocks.toastError).toHaveBeenCalledWith( + "Orca couldn't confirm what happened. Check the chat." + ) + } + expect(mocks.enqueueSettingsWrite).not.toHaveBeenCalled() + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption') + ).toHaveLength(1) + } + ) it('includes one background task id in the cancel fingerprint and payload', async () => { mocks.call.mockImplementation((_target, method) => @@ -454,7 +482,7 @@ describe('useStructuredAgentSession options', () => { }) }) - it('remembers a model pick so the next launch seeds the pair the provider settled on', async () => { + it('remembers only the admitted model rather than reply-only companion effort', async () => { mocks.call.mockImplementation((_target, method) => method === 'agentSession.options' ? Promise.resolve(OPTIONS) @@ -467,28 +495,18 @@ describe('useStructuredAgentSession options', () => { } }) ) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) }) - expect(seededByNextLaunch()).toEqual({ model: 'gpt-fast', effort: 'low' }) + expect(seededByNextLaunch()).toEqual({ model: 'gpt-fast' }) expect(mocks.enqueueSettingsWrite).toHaveBeenCalledWith(LOCAL_TARGET, { type: 'apply-picks', agent: 'codex', - picks: [ - { modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }, - { modelId: 'gpt-fast', optionId: 'effort', value: 'low' } - ] + picks: [{ modelId: 'gpt-fast', optionId: 'model', value: 'gpt-fast' }] }) }) @@ -518,7 +536,8 @@ describe('useStructuredAgentSession options', () => { expect(mocks.enqueueSettingsWrite).toHaveBeenCalledWith( remoteTarget, - expect.objectContaining({ type: 'apply-picks', agent: 'codex' }) + expect.objectContaining({ type: 'apply-picks', agent: 'codex' }), + { expectedEnvironmentPairingRevision: 1 } ) }) @@ -531,14 +550,7 @@ describe('useStructuredAgentSession options', () => { value: { key: 'effort', value: 'high', options: { effort: 'high' } } }) ) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { @@ -549,24 +561,17 @@ describe('useStructuredAgentSession options', () => { expect(seededByNextLaunch()).toEqual({ model: 'gpt-live', effort: 'high' }) }) - it('remembers nothing when the provider refuses the pick', async () => { + it('remembers nothing when the transport leaves the pick unconfirmed', async () => { mocks.call.mockImplementation((_target, method) => method === 'agentSession.options' ? Promise.resolve(OPTIONS) : Promise.reject(new Error('provider rejected option')) ) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) await act(async () => { - expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) }) expect(mocks.enqueueSettingsWrite).not.toHaveBeenCalled() @@ -627,14 +632,7 @@ describe('useStructuredAgentSession options', () => { } }) }) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(3)) await act(async () => { @@ -649,15 +647,10 @@ describe('useStructuredAgentSession options', () => { expect(mocks.enqueueSettingsWrite).toHaveBeenCalledWith(LOCAL_TARGET, { type: 'apply-picks', agent: 'codex', - picks: [ - { modelId: 'gpt-live', optionId: 'model', value: 'gpt-live' }, - { modelId: 'gpt-live', optionId: 'effort', value: 'medium' }, - { modelId: 'gpt-live', optionId: 'fastMode', value: true } - ] + picks: [{ modelId: 'gpt-live', optionId: 'fastMode', value: true }] }) expect(seededByNextLaunch()).toEqual({ model: 'gpt-live', - effort: 'medium', fastMode: 'true' }) }) @@ -691,14 +684,7 @@ describe('useStructuredAgentSession options', () => { } }) }) - const { result, rerender } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target: LOCAL_TARGET, - agent: 'codex', - isVisible: true - }) - ) + const { result, rerender } = renderOptionsSession() await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(3)) items = [ { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 9d617b0b45b..760830bd551 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -107,6 +107,7 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs) turnId: transportState.turnId, unloadedTurnRevisions: state.unloadedTurnRevisions, mutate, + capturePresentation, ...(launch ? { launch } : {}) }) const sendOptions = useStructuredAgentSessionSendOptions(sessionId, launch, sendSource) @@ -137,7 +138,7 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs) historyLoaded: transportEnabled && state.status === 'ready', isWorking: transportState.isWorking, stopping: stopControl.stopping, - sendOptions + sendSelection: sendOptions }) const usage = useStructuredAgentSessionContextUsage(journalItems, usageSupport, state.epoch) diff --git a/src/renderer/src/components/native-chat/use-structured-session-selection-reads.ts b/src/renderer/src/components/native-chat/use-structured-session-selection-reads.ts new file mode 100644 index 00000000000..47eabdefbcf --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-session-selection-reads.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' +import type { structuredSessionSelectionAuthority } from '@/lib/structured-agent-session-selection-controller' +import type { StructuredSessionSelectionController } from '../../../../shared/structured-agent-session-selection-operations' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' + +/** Revalidate the queried base when local operations finish or host authority moves. */ +export function useStructuredSessionSelectionReads(args: { + controller: StructuredSessionSelectionController + target: RuntimeClientTarget + authority: ReturnType + hostOptionStamp: string + turnId: string | null + rereadOptions: () => void +}) { + const { controller, target, authority, hostOptionStamp, turnId, rereadOptions } = args + const [, setContact] = useState(0) + const selectionRevision = useSyncExternalStore(controller.subscribe, controller.snapshot) + const terminal = controller.terminalSnapshot() + const readStamp = JSON.stringify([hostOptionStamp, turnId]) + const seenReadStamp = useRef(readStamp) + useEffect(() => { + if (seenReadStamp.current !== readStamp) { + seenReadStamp.current = readStamp + rereadOptions() + } + }, [readStamp, rereadOptions]) + useEffect(() => { + if (terminal) { + rereadOptions() + } + }, [terminal, rereadOptions]) + useEffect( + () => + target.kind === 'environment' + ? subscribeRuntimeHostContactRegained(target.environmentId, () => { + setContact((value) => value + 1) + rereadOptions() + }) + : undefined, + [target, authority, controller, rereadOptions] + ) + return selectionRevision +} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx index fbca8268f3a..67a21fcfdad 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx @@ -460,7 +460,7 @@ function registerLaunch( promise: new Promise(() => undefined), visibilityUnknown: false, cancelled: false, - selection: { held: {} } + selection: {} } satisfies StructuredLaunchState) } diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts index 20ffb995c24..f3bd5a4730e 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts @@ -22,6 +22,7 @@ import { type StructuredLaunchState } from './structured-agent-session-launch-registry' import { recordUnlistedCreatedChat } from '../../structured-agent-session-unlisted-created-chats' +import { forgetStructuredSessionSelection } from '../../structured-agent-session-selection-controller' function cleanupLaunchState(state: StructuredLaunchState): void { if (deleteStructuredLaunchStateIfCurrent(state)) { @@ -30,11 +31,7 @@ function cleanupLaunchState(state: StructuredLaunchState): void { } export function maybeCleanupLaunchState(state: StructuredLaunchState): void { - if ( - state.callers.outcome === 'failed' || - structuredLaunchCallersHavePendingWork(state.callers) || - Object.keys(state.selection.held).length > 0 - ) { + if (state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers)) { return } cleanupLaunchState(state) @@ -47,7 +44,10 @@ function cleanupUnmaterializedLaunch(state: StructuredLaunchState): boolean { state.intent.worktreeId, state.intent.sessionId ) - return deleteStructuredLaunchStateIfCurrent(state) + if (deleteStructuredLaunchStateIfCurrent(state)) { + forgetStructuredSessionSelection(state.intent.sessionId, state.intent.target) + return true + } } return false } diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-registry.ts index 3a7df8a22e7..19c9828ebe0 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-registry.ts @@ -1,5 +1,8 @@ import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { launchStateLifecycle } from '../../structured-agent-session-launch-lifecycle' +export { launchStateLifecycle } from '../../structured-agent-session-launch-lifecycle' +import { forgetStructuredSessionSelection } from '../../structured-agent-session-selection-controller' import { useSyncExternalStore } from 'react' import type { TuiAgent } from '../../../../../shared/tui-agent' import type { StructuredLaunchFailure } from '../../structured-agent-session-launch-failure' @@ -60,6 +63,9 @@ const structuredLaunchesBySessionId = new Map() const structuredLaunchListeners = new Set<() => void>() export function resetStructuredAgentLaunchRegistryForTests(): void { + for (const sessionId of structuredLaunchesBySessionId.keys()) { + forgetStructuredSessionSelection(sessionId) + } structuredLaunchesBySessionId.clear() structuredLaunchListeners.clear() resetStructuredAgentLaunchCancellationForTests() @@ -135,21 +141,6 @@ export function structuredLaunchStates(): IterableIterator ({ })) import { retryStructuredAgentSessionLaunch } from '../../structured-agent-session-launch' +import { structuredLaunchOptionOverlay } from '../../structured-agent-session-launch-options' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' import { resetStructuredAgentLaunchRegistryForTests, @@ -192,10 +193,11 @@ describe('structured agent launch reload recovery', () => { .mockResolvedValueOnce([publishedSnapshot(worktreeId, sessionId)]) expect(retryStructuredAgentSessionLaunch(worktreeId, sessionId)).toBe(true) - expect(getStructuredLaunchStateBySessionId(sessionId)?.selection).toMatchObject({ - explicit: { model: 'action-model', fastMode: 'false' }, - held: {} + const restored = getStructuredLaunchStateBySessionId(sessionId)! + expect(restored.selection).toMatchObject({ + explicit: { model: 'action-model', fastMode: 'false' } }) + expect(structuredLaunchOptionOverlay(restored)).toEqual({}) await flushLaunchSettlement() expect(mocks.launch).toHaveBeenCalledWith( diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts index 7c3a1a1a455..0dec2152ae6 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts @@ -13,7 +13,10 @@ import { structuredLaunchIdentity, type StructuredLaunchState } from './structured-agent-session-launch-registry' -import { initialStructuredLaunchSelection } from '../../structured-agent-session-launch-options' +import { + initialStructuredLaunchSelection, + structuredLaunchSelectionController +} from '../../structured-agent-session-launch-options' export function restorePersistedStructuredLaunchState( worktreeId: string, @@ -70,6 +73,7 @@ export function restorePersistedStructuredLaunchState( } callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown' callers.failedAt = record.failedAt + structuredLaunchSelectionController(state) setStructuredLaunchState(state) return state } diff --git a/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts index 25d66729e1f..66413f9c852 100644 --- a/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts +++ b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts @@ -106,7 +106,7 @@ function setLaunch( promise, visibilityUnknown: false, cancelled: false, - selection: { held: {} } + selection: {} } satisfies StructuredLaunchState) } @@ -280,7 +280,7 @@ describe('structured launch cancellation retirement', () => { promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), visibilityUnknown: false, cancelled: false, - selection: { held: {} } + selection: {} } satisfies StructuredLaunchState) markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index f6ce28ffce6..6041f775962 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -43,6 +43,7 @@ export type StructuredAgentSessionLaunchIntent = { target: RuntimeClientTarget agent: TuiAgent params: StructuredAgentSessionCreateParams + sendSelectionSupport?: boolean createMessageSupport?: boolean createPrepared?: true prepareCreate?: (hostTakesExplicitOptions: boolean) => void @@ -171,6 +172,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { firstMessage?: StructuredAgentSessionFirstMessage optionsMode?: 'replace-model-selection' options?: Readonly> + sendSelectionSupport?: boolean createMessageSupport?: boolean }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() diff --git a/src/renderer/src/lib/structured-agent-session-launch-action-effort.test-fixture.ts b/src/renderer/src/lib/structured-agent-session-launch-action-effort.test-fixture.ts index f6a342f4e2d..510de9ca9c4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-action-effort.test-fixture.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-action-effort.test-fixture.ts @@ -9,6 +9,7 @@ import { newAgentLaunchRequestId } from './agent-launch-request-id' export function registerActionEffortCreateTests( mocks: { settings: Record + exactSupport: Mock call: Mock<(target: unknown, method: string, params: unknown) => Promise> }, created: () => unknown @@ -18,6 +19,7 @@ export function registerActionEffortCreateTests( '%s action effort overlays remembered effort in create and yields to a held pick', async (agent) => { const model = agent === 'claude' ? 'claude-opus-5-5[1m]' : 'gpt-5.4' + mocks.exactSupport.mockResolvedValue(true) mocks.settings = { nativeChatSessionOptions: { [agent]: { model, valuesByModel: { [model]: { effort: 'low' } } } @@ -60,6 +62,7 @@ export function registerActionEffortCreateTests( expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }]) expect(params.optionsMode).toBeUndefined() expect(params.options).toMatchObject({ effort: held ?? 'high' }) + expect(params.firstMessage?.selection).toMatchObject({ effort: held ?? 'high' }) if (agent === 'claude') { expect(params.options?.model).toBe(model) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index 6504ba8af3f..b0a0ac44b80 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -12,6 +13,7 @@ import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-writ import { callStructuredAgentSession, supportsStructuredAgentSessionCreateOptionsMode, + structuredAgentSessionHostSupports, supportsStructuredAgentSessionCreateMessage } from '@/runtime/structured-agent-session-client' import type { StructuredAgentSessionCreateParams } from '../../../shared/structured-agent-session-create' @@ -125,6 +127,10 @@ export async function launchStructuredAgentSession( supported && intent.explicitLaunchOptions !== undefined && (await supportsStructuredAgentSessionCreateOptionsMode(intent.target)) + intent.sendSelectionSupport = await structuredAgentSessionHostSupports( + intent.target, + AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY + ) } catch { // Unknown capability stays on the compatible create path. } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test-fixture.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test-fixture.ts new file mode 100644 index 00000000000..81a26f2ef15 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test-fixture.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom +import { vi, type Mock } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire' +import { + CreateIntentParams, + SetOptionParams, + SendParams +} from '../../../shared/rpc-contract/structured-agent-session-params' + +import type { HostCreateSupport } from './structured-agent-session-host-admission' + +type CreateMessageFixtureMocks = { + call: Mock<(target: unknown, method: string, params: unknown) => Promise> + supports: Mock<() => Promise> + supportsMode: Mock<() => Promise> + exactSupport: Mock<() => Promise> + admit: Mock<() => Promise> + inventory: Mock + settings: Record +} + +const mocks: CreateMessageFixtureMocks = vi.hoisted(() => { + const settings: Record = {} + return { + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + supports: vi.fn<() => Promise>(), + supportsMode: vi.fn<() => Promise>(), + exactSupport: vi.fn<() => Promise>(), + admit: vi.fn<() => Promise>(), + inventory: vi.fn(), + settings + } +}) +const layoutOwner = vi.hoisted(() => ({ isMain: false })) +vi.mock('../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.isMain + } +})) +// This machine's host closes the chat's tab when asked. +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn(async () => ({})) })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionCreateOptionsMode: mocks.supportsMode, + supportsStructuredAgentSessionCreateMessage: mocks.supports, + structuredAgentSessionHostSupports: mocks.exactSupport +})) +vi.mock('./structured-agent-session-host-admission', () => ({ + askHostCreateSupport: mocks.admit +})) +vi.mock('@/runtime/structured-session-tab-inventory', () => ({ + readStructuredSessionTabInventory: mocks.inventory +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + runtimeEnvironmentCatalogHydrated: true, + settings: mocks.settings, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + }), + subscribe: () => () => {} + } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' }) +})) + +import { useAppStore } from '@/store' +import { registerStructuredSelectionCatalogStore } from './structured-agent-session-selection-controller' +import { resetStructuredAgentLaunchRegistryForTests } from './agent-session/launch/structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchPersistenceForTests } from './agent-session/launch/structured-agent-session-launch-persistence' +import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' +import { resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { startStructuredAgentLaunch } from './structured-agent-session-launch' +import { newAgentLaunchRequestId } from './agent-launch-request-id' + +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from '@/components/native-chat/native-chat-draft-cache' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' + +export function resetCreateMessageFixture() { + const restore = registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe: useAppStore.subscribe + }) + vi.useFakeTimers() + vi.clearAllMocks() + layoutOwner.isMain = false + localStorage.clear() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + clearNativeChatDraftCacheForTests() + mocks.supports.mockResolvedValue(true) + mocks.supportsMode.mockResolvedValue(true) + mocks.exactSupport.mockResolvedValue(false) + resetHostModelCatalogSnapshotsForTests() + mocks.admit.mockResolvedValue({ kind: 'admitted' }) + mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) + return restore +} + +export function cleanupCreateMessageFixture(restore?: () => void) { + resetStructuredAgentLaunchRegistryForTests() + restore?.() + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + vi.useRealTimers() +} + +function deferred() { + return Promise.withResolvers() +} +function createParams() { + return CreateIntentParams.parse( + mocks.call.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] + ) +} +function submission(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'pending', + handoverRecorded: true, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } +} +function created() { + const params = createParams() + const rows = params.firstMessage ? [submission(params.firstMessage.clientMessageId)] : [] + const cursor = { epoch: 'epoch-1', sequence: rows.length } + const page: AgentSessionHistoryPage = { + sessionId: params.envelope.sessionId, + epoch: cursor.epoch, + fence: 1, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: rows, + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false, + latestTurn: null + } + return { + ok: true, + replayed: false, + value: { sessionId: page.sessionId, fence: 1, page, unconfirmedClientMessageIds: [] } + } +} +const target = { kind: 'local' } as const +const start = () => + startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text' + }) +const draft = (sessionId: string) => + readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId)) + +// An older host (no create-message) applies an action's explicit options one setOption at a time. +function startLegacyActionLaunch( + launchOptions: Record, + refusedKey: string, + refusal: Record | Error +) { + mocks.supports.mockResolvedValue(false) + mocks.call.mockImplementation(async (_target, method, params) => { + if (method === 'agentSession.create') { + return created() + } + if (method === 'agentSession.setOption') { + const key = params && typeof params === 'object' && 'key' in params ? params.key : null + if (key === refusedKey && refusal instanceof Error) { + throw refusal + } + return key === refusedKey + ? { ok: false, refusal } + : { ok: true, value: { options: { model: launchOptions.model } } } + } + const send = SendParams.parse(params) + const clientMessageId = send.envelope.clientOperationId + return { ok: true, value: { clientMessageId, submission: submission(clientMessageId) } } + }) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions + }) + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + return launch +} + +export { + mocks, + layoutOwner, + deferred, + createParams, + submission, + created, + target, + start, + draft, + startLegacyActionLaunch +} + +export function legacyAction( + explicit: Record | undefined, + write: (key: string, value: string) => Promise, + executionHostId: ExecutionHostId = 'local', + agent: 'claude' | 'codex' = 'codex' +) { + const create = deferred() + mocks.supports.mockResolvedValue(false) + mocks.call.mockImplementation(async (_target, method, params) => { + if (method === 'agentSession.create') { + return create.promise + } + if (method === 'agentSession.setOption') { + const { key, value } = SetOptionParams.parse(params) + return write(key, value) + } + if (method === 'agentSession.history') { + return { ok: true, page: { ...created().value.page, fence: 2 } } + } + const send = SendParams.parse(params) + return { + ok: true, + value: { + clientMessageId: send.envelope.clientOperationId, + submission: submission(send.envelope.clientOperationId) + } + } + }) + const launch = startStructuredAgentLaunch('workspace-1', agent, { + requestId: newAgentLaunchRequestId(), + executionHostId, + prompt: 'opening text', + ...(explicit ? { launchOptions: explicit } : {}), + ...(executionHostId !== 'local' ? { hostSeedOptions: { model: 'gpt-5.5' } } : {}) + }) + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + return { launch, create } +} +export const acceptedOption = (key: string, value: string) => ({ + ok: true, + value: { key, value, options: { [key]: value } } +}) +export const refusedOption = { + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } +} +export const optionCalls = () => + mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.setOption') + .map(([, , params]) => SetOptionParams.parse(params)) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 79155d034dc..1b1d30bd3ba 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -1,7 +1,21 @@ // @vitest-environment happy-dom +import { + mocks, + resetCreateMessageFixture, + cleanupCreateMessageFixture, + layoutOwner, + deferred, + createParams, + submission, + created, + target, + start, + draft, + startLegacyActionLaunch +} from './structured-agent-session-launch-create-message.test-fixture' +import { useAppStore } from '@/store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire' + import { CreateIntentParams, SendParams @@ -9,54 +23,6 @@ import { import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' import { registerActionEffortCreateTests } from './structured-agent-session-launch-action-effort.test-fixture' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import type { HostCreateSupport } from './structured-agent-session-host-admission' - -const mocks = vi.hoisted(() => { - const settings: Record = {} - return { - call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - supports: vi.fn<() => Promise>(), - supportsMode: vi.fn<() => Promise>(), - admit: vi.fn<() => Promise>(), - inventory: vi.fn(), - settings - } -}) -const layoutOwner = vi.hoisted(() => ({ isMain: false })) -vi.mock('../../../shared/layout-owner-switch', () => ({ - get ORCA_LAYOUT_OWNER_IS_MAIN() { - return layoutOwner.isMain - } -})) -// This machine's host closes the chat's tab when asked. -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn(async () => ({})) })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - supportsStructuredAgentSessionCreateOptionsMode: mocks.supportsMode, - supportsStructuredAgentSessionCreateMessage: mocks.supports -})) -vi.mock('./structured-agent-session-host-admission', () => ({ - askHostCreateSupport: mocks.admit -})) -vi.mock('@/runtime/structured-session-tab-inventory', () => ({ - readStructuredSessionTabInventory: mocks.inventory -})) -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ - settings: mocks.settings, - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - seedNativeChatLaunchDraft: vi.fn(), - clearNativeChatLaunchDraft: vi.fn() - }), - subscribe: () => () => {} - } -})) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) -vi.mock('@/runtime/structured-chat-host-permission', () => ({ - readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' }) -})) import { startStructuredAgentLaunch, @@ -64,7 +30,17 @@ import { retryStructuredAgentSessionLaunch, getStructuredAgentSessionLaunchLifecycle } from './structured-agent-session-launch' -import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' +import { + registerStructuredSelectionCatalogStore, + structuredSessionSelectionController, + forgetStructuredSessionSelection +} from './structured-agent-session-selection-controller' +import { setRuntimeEnvironmentConnectionGenerationForTests } from '@/store/slices/runtime-status-connection-generation' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { + holdStructuredAgentSessionLaunchOption, + structuredLaunchOptionOverlay +} from './structured-agent-session-launch-options' import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' import { resetStructuredAgentLaunchRegistryForTests, @@ -79,8 +55,7 @@ import { markStructuredAgentSessionLaunchPublished } from './agent-session/launc import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' import { getStructuredAgentSessionReadOwner, - findStructuredAgentSessionReadOwner, - resetStructuredAgentSessionReadOwnersForTests + findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' import { @@ -88,132 +63,64 @@ import { getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends' import { structuredAgentSessionStopControl } from '../../../shared/structured-agent-session-stop-control' -import { - clearNativeChatDraftCacheForTests, - readNativeChatDraftCache, - writeNativeChatDraftCache -} from '@/components/native-chat/native-chat-draft-cache' +import { writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' - -function deferred() { - return Promise.withResolvers() -} -function createParams() { - return CreateIntentParams.parse( - mocks.call.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] - ) -} -function submission(clientMessageId: string): AgentJournalSubmission { - return { - clientMessageId, - fence: 1, - payloadFingerprint: 'a'.repeat(64), - dispatchState: 'pending', - handoverRecorded: true, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } -} -function created() { - const params = createParams() - const rows = params.firstMessage ? [submission(params.firstMessage.clientMessageId)] : [] - const cursor = { epoch: 'epoch-1', sequence: rows.length } - const page: AgentSessionHistoryPage = { - sessionId: params.envelope.sessionId, - epoch: cursor.epoch, - fence: 1, - direction: 'tail', - items: [], - removedItemIds: [], - submissions: rows, - window: { oldest: null, newest: null, nextCursor: cursor }, - liveCursor: cursor, - hasOlder: false, - hasNewer: false, - latestTurn: null - } - return { - ok: true, - replayed: false, - value: { sessionId: page.sessionId, fence: 1, page, unconfirmedClientMessageIds: [] } - } -} -const target = { kind: 'local' } as const -const start = () => - startStructuredAgentLaunch('workspace-1', 'codex', { - requestId: newAgentLaunchRequestId(), - executionHostId: 'local', - prompt: 'opening text' - }) -const draft = (sessionId: string) => - readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId)) - -// An older host (no create-message) applies an action's explicit options one setOption at a time. -function startLegacyActionLaunch( - launchOptions: Record, - refusedKey: string, - refusal: Record -) { - mocks.supports.mockResolvedValue(false) - mocks.call.mockImplementation(async (_target, method, params) => { - if (method === 'agentSession.create') { - return created() - } - if (method === 'agentSession.setOption') { - const key = params && typeof params === 'object' && 'key' in params ? params.key : null - return key === refusedKey - ? { ok: false, refusal } - : { ok: true, value: { options: { model: launchOptions.model } } } - } - const send = SendParams.parse(params) - const clientMessageId = send.envelope.clientOperationId - return { ok: true, value: { clientMessageId, submission: submission(clientMessageId) } } - }) - const launch = startStructuredAgentLaunch('workspace-1', 'codex', { - requestId: newAgentLaunchRequestId(), - executionHostId: 'local', - prompt: 'opening text', - launchOptions - }) - mocks.inventory.mockResolvedValue([ - { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } - ]) - return launch -} - +let restoreCatalogStore: (() => void) | undefined describe('the first message owned by create', () => { beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - layoutOwner.isMain = false - localStorage.clear() - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() - resetStructuredAgentSessionSendsForTests() - resetStructuredAgentSessionReadOwnersForTests() - clearNativeChatDraftCacheForTests() - mocks.supports.mockResolvedValue(true) - mocks.supportsMode.mockResolvedValue(true) - mocks.admit.mockResolvedValue({ kind: 'admitted' }) - mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) + restoreCatalogStore = resetCreateMessageFixture() }) afterEach(() => { - resetStructuredAgentSessionSendsForTests() - resetStructuredAgentSessionReadOwnersForTests() - vi.useRealTimers() + cleanupCreateMessageFixture(restoreCatalogStore) + }) + + it('requires registered authority before inserting a remote selection owner', () => { + restoreCatalogStore?.() + const remote = { kind: 'environment', environmentId: 'uncomposed-test-store' } as const + expect(() => structuredSessionSelectionController(remote, 'uncomposed', 'codex')).toThrow( + 'must be registered' + ) + const restore = registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe: useAppStore.subscribe + }) + try { + const owner = structuredSessionSelectionController(remote, 'uncomposed', 'codex') + expect(structuredSessionSelectionController(remote, 'uncomposed', 'codex')).toBe(owner) + } finally { + forgetStructuredSessionSelection('uncomposed', remote) + restore() + } }) it('freezes the message and held options in create and publishes without another read or send', async () => { + mocks.exactSupport.mockResolvedValue(true) + recordHostModelCatalogSnapshot(target, 'codex', null, { + origin: 'probe', + fetchedAt: 1, + defaultHoldsInEveryWorkspace: true, + models: [ + { + id: 'picked-model', + label: 'Picked', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ] + }) const create = deferred() mocks.call.mockReturnValue(create.promise) const launch = start() holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'picked-model') + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high') await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const params = createParams() expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }]) - expect(params.options).toEqual({ model: 'picked-model' }) + expect(params.options).toEqual({ model: 'picked-model', effort: 'high' }) + expect(params.firstMessage?.selection).toEqual({ model: 'picked-model', effort: 'high' }) expect(params.envelope.payloadFingerprint).toBe( structuredAgentSessionCreateFingerprint({ sessionId: launch.sessionId, ...params }) ) @@ -254,6 +161,7 @@ describe('the first message owned by create', () => { ])( "keeps a remembered model the host's saved list lacks, %s: create sends the literal saved choice", async (_kind, resume) => { + mocks.exactSupport.mockResolvedValue(true) mocks.settings = { nativeChatSessionOptions: { claude: { model: 'claude-gone' } @@ -275,6 +183,7 @@ describe('the first message owned by create', () => { }) await launch.launchResult expect(createParams().options?.model).toBe('claude-gone') + expect(createParams().firstMessage?.selection).toEqual({ model: 'claude-gone' }) } finally { mocks.settings = {} } @@ -302,7 +211,9 @@ describe('the first message owned by create', () => { expect(readStructuredAgentLaunchRecord(launch.sessionId)?.explicitLaunchOptions).toEqual({ model: 'unlisted-action-model' }) - expect(getStructuredLaunchStateBySessionId(launch.sessionId)?.selection.held).toEqual({}) + expect( + structuredLaunchOptionOverlay(getStructuredLaunchStateBySessionId(launch.sessionId)!) + ).toEqual({}) await launch.launchResult expect(createParams().optionsMode).toBe('replace-model-selection') expect(createParams().options).toEqual({ @@ -425,6 +336,7 @@ describe('the first message owned by create', () => { const create = deferred() mocks.call.mockReturnValue(create.promise) const launch = start() + const held = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'gpt-5.4')! const state = getStructuredLaunchStateBySessionId(launch.sessionId) if (state) { state.intent.surfaceAddress = { worktreeId: 'workspace-1', precedingTabIds: [] } @@ -446,6 +358,7 @@ describe('the first message owned by create', () => { expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) expect(getStructuredAgentSessionPendingSends(launch.sessionId)).toEqual([]) expect(findStructuredAgentSessionReadOwner(launch.sessionId, target)).toBeUndefined() + await expect(held).resolves.toEqual({ kind: 'superseded' }) }) it('returns text at 30 seconds and a late create reply cannot send it again', async () => { @@ -524,14 +437,24 @@ describe('the first message owned by create', () => { expect(draft(launch.sessionId)).toBe('') }) - it.each(['model', 'effort'])( - 'keeps the legacy chat and text when required %s is refused, with no fresh Retry', - async (refused) => { - const launch = startLegacyActionLaunch({ model: 'action-model', effort: 'high' }, refused, { - code: 'agent_session_operation_invalid', - details: { reason: 'optionRejected' }, - message: 'unavailable' - }) + it.each([ + { key: 'model', unknown: false }, + { key: 'effort', unknown: false }, + { key: 'model', unknown: true } + ])( + 'keeps the legacy chat and text when required input fails: %j', + async ({ key: refused, unknown }) => { + const launch = startLegacyActionLaunch( + { model: 'action-model', effort: 'high' }, + refused, + unknown + ? new Error('runtime_unavailable') + : { + code: 'agent_session_operation_invalid', + details: { reason: 'optionRejected' }, + message: 'unavailable' + } + ) const receipt = await launch.launchResult expect(receipt).toMatchObject({ sessionId: launch.sessionId, configurationFailure: {} }) await expect(launch.promptDeliveryResult).resolves.toMatchObject({ @@ -633,15 +556,17 @@ describe('the first message owned by create', () => { ).toBeNull() }) - it('publishes while a later option write still waits for the agent', async () => { + it('enqueues two late picks before modern publication without waiting for acceptance', async () => { const create = deferred() - const option = deferred() + const replies = [deferred(), deferred()] + let writes = 0 mocks.call.mockImplementation((_target, method) => - method === 'agentSession.setOption' ? option.promise : create.promise + method === 'agentSession.setOption' ? replies[writes++]!.promise : create.promise ) const launch = start() await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'later-pick') + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'sonnet') + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high') create.resolve(created()) let published = false void launch.launchResult.then(() => { @@ -652,9 +577,14 @@ describe('the first message owned by create', () => { await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ 'agentSession.create', + 'agentSession.setOption', 'agentSession.setOption' ]) - option.resolve({ ok: true, value: { selected: 'later-pick' } }) + const controller = structuredSessionSelectionController(target, launch.sessionId, 'codex') + expect(controller.pending()).toHaveLength(2) + replies[1]!.resolve({ ok: true, value: { options: { effort: 'high' } } }) + replies[0]!.resolve({ ok: true, value: { options: { model: 'sonnet' } } }) + await vi.waitFor(() => expect(controller.captureRead()).toHaveLength(2)) }) it('recovers a reload by replaying the frozen create when the old row is outside history', async () => { @@ -707,13 +637,35 @@ describe('the first message owned by create', () => { it('allows Retry after the deadline while the original create never answers', async () => { const create = deferred() mocks.call.mockReturnValue(create.promise) - const launch = start() + const paired = { kind: 'environment', environmentId: 'frozen-generation' } as const + replaceRuntimeEnvironmentRevisions([{ id: paired.environmentId, createdAt: 1 }]) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'runtime:frozen-generation', + hostSeedOptions: { model: 'gpt-5.5' }, + prompt: 'opening text' + }) + const first = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'gpt-5.4')! await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const original = createParams() + const owner = structuredSessionSelectionController(paired, launch.sessionId, 'codex') + const carried = owner.pending()[0]! await vi.advanceTimersByTimeAsync(30_000) await launch.promptDeliveryResult + setRuntimeEnvironmentConnectionGenerationForTests(paired.environmentId, 2) + expect(structuredSessionSelectionController(paired, launch.sessionId, 'codex')).toBe(owner) + const middle = holdStructuredAgentSessionLaunchOption( + launch.sessionId, + 'model', + 'gpt-5.3-codex' + )! + const newest = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'gpt-5.4')! + await expect(middle).resolves.toEqual({ kind: 'superseded' }) + const applied = deferred() mocks.inventory.mockResolvedValue([]) - mocks.call.mockImplementation(async () => created()) + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.setOption' ? applied.promise : Promise.resolve(created()) + ) expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) await vi.waitFor(() => expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() @@ -721,8 +673,16 @@ describe('the first message owned by create', () => { const creates = mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') expect(creates).toHaveLength(2) expect(creates[1]?.[2]).toEqual(original) + await expect(first).resolves.toMatchObject({ kind: 'accepted' }) + expect(carried.outcome?.kind).toBe('accepted') + expect(owner.pending()).toHaveLength(1) + expect(owner.pending()[0]).not.toBe(carried) + applied.resolve({ ok: true, value: { options: { model: 'gpt-5.4' } } }) + await expect(newest).resolves.toMatchObject({ kind: 'accepted' }) + expect(owner.captureRead()).toHaveLength(2) expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual([]) expect(draft(launch.sessionId)).toBe('opening text') + replaceRuntimeEnvironmentRevisions([]) }) it.each([ diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 085b6c9bf47..e6a734eb388 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -13,6 +13,7 @@ import { } from './structured-agent-session-launch-prompt' import type { StructuredAgentLaunchReceipt } from './agent-session/launch/structured-agent-session-launch-recovery' import { + structuredAgentSessionMessageSelection, structuredAgentSessionOptionView, structuredAgentSessionSendSelection } from '../../../shared/structured-agent-session-option-view' @@ -21,6 +22,7 @@ import { applyStructuredLaunchHeldOptions, applyStructuredLaunchExplicitOptions, holdStructuredLaunchCreatePicks, + structuredLaunchOptionOverlay, settleStructuredLaunchCreateOptions } from './structured-agent-session-launch-options' import { structuredAgentSessionDrawnValues } from '../../../shared/structured-agent-session-option-snapshot' @@ -51,14 +53,16 @@ export function prepareStructuredLaunchCreateMessage( return } const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) - const requested = structuredLaunchCreateOptions(state) + const { options: requested, selection } = structuredLaunchCreateOptions(state) const options = requested && state.selection.explicit && !optionsMode ? structuredLaunchCompatibleOptions(intent.agent, requested) : requested const params = { ...intent.params, - ...(firstMessage ? { firstMessage } : {}), + ...(firstMessage + ? { firstMessage: { ...firstMessage, ...(selection !== undefined ? { selection } : {}) } } + : {}), ...(options ? { options } : {}), ...(optionsMode ? { optionsMode } : {}) } @@ -90,38 +94,51 @@ function omitStructuredLaunchPermissionMode(state: StructuredLaunchState): void } /** The first turn uses held picks over the action's choices over the chat's shown selection. */ -function structuredLaunchCreateOptions( - state: StructuredLaunchState -): Readonly> | undefined { - const { intent, selection } = state - const requested = structuredLaunchRequestedOptions(selection) +function structuredLaunchCreateOptions(state: StructuredLaunchState): { + options?: Readonly> + selection?: Readonly> +} { + const { intent } = state + const overlay = structuredLaunchOptionOverlay(state) + const requested = structuredLaunchRequestedOptions({ ...state.selection, held: overlay }) const firstFrame = firstFrameOptionState(intent.agent, intent.target, { newLaunch: !intent.params.resumeFrom, worktree: intent.params.worktree, seedsModel: intent.seedOptions?.model !== undefined }) // Create has always carried options, so any host takes what the chat shows. - const view = structuredAgentSessionOptionView(firstFrame, intent.seedOptions, selection.held) + const view = structuredAgentSessionOptionView( + firstFrame, + intent.seedOptions, + { ...requested, ...overlay }, + state.selection.explicit + ) const drawn = structuredAgentSessionDrawnValues(view) // Each held value as the picker draws it; the held picks themselves stay as the user made them. const held = Object.fromEntries( - Object.entries(selection.held).map(([id, encoded]) => { + Object.entries(overlay).map(([id, encoded]) => { const value = id === 'model' ? undefined : drawn[id]?.value const shown = value === undefined ? null : encodeStructuredAgentSessionOptionValue(id, value) return [id, shown ?? encoded] }) ) - holdStructuredLaunchCreatePicks(state, selection.held) - if (requested?.model !== undefined) { - // The view draws under a re-picked model only; an action's own model keeps picks as made. - return requested.model === selection.held.model ? { ...requested, ...held } : requested + holdStructuredLaunchCreatePicks(state) + const options = + requested?.model !== undefined + ? requested.model === overlay.model + ? { ...requested, ...held } + : requested + : { + ...structuredAgentSessionSendSelection(view), + ...requested, + ...held + } + return { + ...(Object.keys(options).length ? { options } : {}), + ...(intent.sendSelectionSupport + ? { selection: structuredAgentSessionMessageSelection(view) } + : {}) } - const options = { - ...structuredAgentSessionSendSelection(view), - ...requested, - ...held - } - return Object.keys(options).length ? options : undefined } /** Later picks use the normal option path without holding publication behind startup. */ @@ -133,7 +150,7 @@ export function publishStructuredLaunchCreateOptions( return applyStructuredLaunchExplicitOptions(state, receipt) } settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) - void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { + void applyStructuredLaunchHeldOptions(state, receipt, false).catch((error: unknown) => { console.warn('[native-chat] applying a launch option failed', error) }) return receipt diff --git a/src/renderer/src/lib/structured-agent-session-launch-lifecycle.ts b/src/renderer/src/lib/structured-agent-session-launch-lifecycle.ts new file mode 100644 index 00000000000..1e901bb32ad --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-lifecycle.ts @@ -0,0 +1,19 @@ +import type { + StructuredLaunchState, + StructuredAgentSessionLaunchLifecycle +} from './agent-session/launch/structured-agent-session-launch-registry' + +export function launchStateLifecycle( + state: StructuredLaunchState +): StructuredAgentSessionLaunchLifecycle { + if (state.cancelled || state.callers.outcome === 'cancelled') { + return 'cancelled' + } + if (state.callers.outcome === 'published') { + return 'published' + } + if (state.visibilityUnknown || state.callers.outcome === 'unknown') { + return 'visibility-unknown' + } + return state.callers.outcome === 'failed' ? 'failed' : 'pending' +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index b8340a8f113..27d45347a93 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -1,7 +1,8 @@ +import { useAppStore } from '@/store' // @vitest-environment happy-dom import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' @@ -13,12 +14,17 @@ type CallParams = { } const mocks = vi.hoisted(() => ({ + settingsWrite: vi.fn(async () => {}), call: vi.fn<(target: unknown, method: string, params: CallParams) => Promise>(), createIntent: vi.fn(), retryIntent: vi.fn(), launch: vi.fn() })) +vi.mock('@/components/native-chat/native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: mocks.settingsWrite +})) + vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) const launchErrorMocks = vi.hoisted(() => { @@ -58,6 +64,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ + runtimeEnvironmentCatalogHydrated: true, unifiedTabsByWorktree: {}, seedNativeChatLaunchDraft: vi.fn(), clearNativeChatLaunchDraft: vi.fn() @@ -84,15 +91,22 @@ import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { + applyStructuredLaunchHeldOptions, getStructuredAgentSessionLaunchSelection, holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' import { resetStructuredAgentLaunchPersistenceForTests } from './agent-session/launch/structured-agent-session-launch-persistence' import { + getStructuredLaunchStateBySessionId, resetStructuredAgentLaunchRegistryForTests, subscribeStructuredAgentLaunchStatus } from './agent-session/launch/structured-agent-session-launch-registry' import { markStructuredAgentSessionLaunchPublished } from './agent-session/launch/structured-agent-session-launch-publication' +import { + registerStructuredSelectionCatalogStore, + structuredSessionSelectionController, + forgetStructuredSessionSelection +} from './structured-agent-session-selection-controller' const WORKTREE_ID = 'wt-1' const SESSION_ID = 'session-1' @@ -172,10 +186,21 @@ async function settle(): Promise { } } +let restoreCatalogStore: (() => void) | undefined +afterEach(() => { + resetStructuredAgentLaunchRegistryForTests() + forgetStructuredSessionSelection(SESSION_ID) + restoreCatalogStore?.() +}) describe('picks made while a chat launches', () => { let setOptionReplies: Deferred[] beforeEach(() => { + restoreCatalogStore = registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe: useAppStore.subscribe + }) + forgetStructuredSessionSelection(SESSION_ID) vi.clearAllMocks() localStorage.clear() resetStructuredAgentLaunchPersistenceForTests() @@ -217,7 +242,8 @@ describe('picks made while a chat launches', () => { markStructuredAgentSessionLaunchPublished(WORKTREE_ID, SESSION_ID, 'local') expect(lifecycle()).toBe('pending') expect(mutations()).toEqual([ - { method: 'agentSession.setOption', fence: 7, key: 'model', value: 'gpt-picked' } + { method: 'agentSession.setOption', fence: 7, key: 'model', value: 'gpt-picked' }, + { method: 'agentSession.setOption', fence: 7, key: 'effort', value: 'high' } ]) setOptionReplies[0]!.resolve({ ok: true, @@ -257,27 +283,21 @@ describe('picks made while a chat launches', () => { expect(mutations().map((mutation) => mutation.value)).toEqual(['gpt-picked', 'gpt-repicked']) }) - it('sends held Fast with the picked model even when its catalog omits that tier', async () => { + it('keeps a held tier across a model pick whose catalog does not offer it', async () => { const created = deferred<{ sessionId: string; fence: number }>() mocks.launch.mockReturnValue(created.promise) const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-tier' }) const tierPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'serviceTier', 'priority') holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-nofast') - expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)?.held).toEqual({ - model: 'gpt-nofast', - serviceTier: 'priority' - }) created.resolve({ sessionId: SESSION_ID, fence: 3 }) await settle() - setOptionReplies[0]!.resolve(ACCEPTED) - await settle() - setOptionReplies[1]!.resolve({ ok: true, value: { options: { serviceTier: 'priority' } } }) + expect(setOptionReplies).toHaveLength(2) + for (const reply of setOptionReplies) { + reply.resolve(ACCEPTED) + } + await expect(tierPick).resolves.toMatchObject({ kind: 'accepted' }) await launch.launchResult - await expect(tierPick).resolves.toEqual({ - kind: 'accepted', - options: { serviceTier: 'priority' } - }) expect(mutations().map(({ key, value }) => [key, value])).toEqual([ ['model', 'gpt-nofast'], ['serviceTier', 'priority'] @@ -362,8 +382,7 @@ describe('picks made while a chat launches', () => { expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID)).toBe(true) expect(getStructuredAgentSessionLaunchSelection(SESSION_ID)).toEqual({ - seed: { model: 'gpt-saved-since' }, - held: { model: 'gpt-picked' } + seed: { model: 'gpt-saved-since' } }) await settle() expect(mutations()).toEqual([ @@ -377,7 +396,13 @@ describe('picks made while a chat launches', () => { startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-6' }) holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked') + const otherHost = { kind: 'environment', environmentId: 'surviving-held-chat' } as const + const survivor = structuredSessionSelectionController(otherHost, SESSION_ID, 'codex') + survivor.issue('model', 'other-host-model', null) cancelStructuredAgentLaunch(WORKTREE_ID, SESSION_ID) + expect(structuredSessionSelectionController(otherHost, SESSION_ID, 'codex')).toBe(survivor) + expect(survivor.overlay()).toEqual({ model: 'other-host-model' }) + forgetStructuredSessionSelection(SESSION_ID, otherHost) created.resolve({ sessionId: SESSION_ID, fence: 1 }) await settle() @@ -385,22 +410,67 @@ describe('picks made while a chat launches', () => { expect(holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-late')).toBeNull() }) - it('folds an accepted pick into what the launch reports it runs', async () => { - mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 }) - startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-7' }) - holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') - await settle() - const seen: unknown[] = [] - subscribeStructuredAgentLaunchStatus(() => - seen.push(getStructuredAgentSessionLaunchSelection(SESSION_ID)) - ) - setOptionReplies[0]!.resolve({ - ok: true, - value: { key: 'effort', value: 'high', options: { model: 'gpt-seeded', effort: 'high' } } - }) - await settle() - - expect(seen[0]).toEqual({ seed: { model: 'gpt-seeded', effort: 'high' }, held: {} }) - expect(lifecycle()).toBeNull() + it('keeps held issue and settlement off the global registry serializer at N and 10N', async () => { + for (const count of [10, 100]) { + mocks.launch.mockReturnValue(new Promise(() => {})) + for (let index = 0; index < count; index++) { + const sessionId = `perf-held-${index}` + const intent = { + ...launchIntent({ model: 'gpt-seeded' }), + sessionId, + worktreeId: `perf-worktree-${index}`, + params: { + ...launchIntent().params, + envelope: { ...launchIntent().params.envelope, sessionId } + } + } + mocks.createIntent.mockReturnValueOnce(intent) + startStructuredAgentLaunch(intent.worktreeId, 'codex', { requestId: sessionId }) + } + const sessionId = 'perf-held-0' + const state = getStructuredLaunchStateBySessionId(sessionId)! + const selection = state.selection + const seed = selection.seed + const changed = vi.fn() + const global = vi.fn() + const unsubscribe = subscribeStructuredAgentLaunchStatus(global) + const controller = structuredSessionSelectionController( + state.intent.target, + sessionId, + 'codex' + ) + const stop = controller.subscribe(changed) + const storage = vi.spyOn(Storage.prototype, 'setItem') + const pick = holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-picked') + await applyStructuredLaunchHeldOptions(state, { sessionId, fence: 1 }, false) + setOptionReplies.at(-1)!.resolve({ ok: true, value: { key: 'model', value: 'gpt-picked' } }) + await expect(pick).resolves.toEqual({ kind: 'accepted', options: { model: 'gpt-picked' } }) + await settle() + expect(state.selection).toBe(selection) + expect(state.selection.seed).toBe(seed) + expect(seed).toEqual({ model: 'gpt-seeded' }) + expect(global).not.toHaveBeenCalled() + expect(storage).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(3) + expect(mocks.settingsWrite).toHaveBeenLastCalledWith(state.intent.target, { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-picked', optionId: 'model', value: 'gpt-picked' }] + }) + console.info( + 'selection-held-notifier-work', + JSON.stringify({ + pendingLaunches: count, + globalNotifications: 0, + serializerWrites: 0, + scopedNotifications: changed.mock.calls.length + }) + ) + storage.mockRestore() + stop() + unsubscribe() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + } }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index efe8291b881..0b1e2f435e0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -1,13 +1,21 @@ +import { + createLegacyPrerequisites, + type LegacyPrerequisites +} from './structured-agent-session-launch-prerequisites' import { useSyncExternalStore } from 'react' +import { structuredSessionSelectionController } from './structured-agent-session-selection-controller' +import type { + SelectionOperation, + SelectionOutcome +} from '../../../shared/structured-agent-session-selection-operations' +import { + structuredAgentSessionOptionPicks, + type StructuredAgentSessionOptionState +} from '../../../shared/structured-agent-session-options' import type { AgentSessionMutationResult, AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { - STRUCTURED_LAUNCH_OPTION_IDS, - structuredLaunchCompatibleOptions, - structuredLaunchRequestedOptions -} from '../../../shared/native-chat/input/native-chat-session-option-defaults' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint @@ -15,13 +23,9 @@ import { import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { createBrowserUuid } from '@/lib/browser-uuid' -import { - StructuredAgentSessionLaunchCancelledError, - type StructuredAgentLaunchReceipt -} from './agent-session/launch/structured-agent-session-launch-recovery' +import type { StructuredAgentLaunchReceipt } from './agent-session/launch/structured-agent-session-launch-recovery' import { getStructuredLaunchStateBySessionId, - notifyStructuredLaunchListeners, subscribeStructuredAgentLaunchStatus, type StructuredLaunchState } from './agent-session/launch/structured-agent-session-launch-registry' @@ -35,12 +39,10 @@ import type { StructuredAgentSessionLaunchIntent } from './launch-structured-age /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { - /** What the host runs as far as the launch knows: the create seed, then each accepted pick. */ + /** The immutable create seed, until a queried host answer replaces it. */ seed?: Readonly> - /** Read-only projection of durable action input; held contains only subsequent edits. */ + /** Immutable action input, separate from later picker operations. */ explicit?: Readonly> - /** Encoded picks made before publish; the launch applies them before it publishes. */ - held: Readonly> /** A paired server has not yet said what create seeds, so nothing can be picked against it. */ seedPending?: true } @@ -51,34 +53,44 @@ export function initialStructuredLaunchSelection( return { seed: intent.seedOptions, ...(intent.explicitLaunchOptions ? { explicit: intent.explicitLaunchOptions } : {}), - held: {}, ...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {}) } } -export type StructuredLaunchOptionOutcome = - | { kind: 'accepted'; options: Readonly> } - | { kind: 'refused'; failure: AgentSessionWriteFailure } - | { kind: 'superseded' } +export type StructuredLaunchOptionOutcome = SelectionOutcome -type OptionReply = (outcome: StructuredLaunchOptionOutcome) => void - -const optionReplies = new WeakMap>() - -function repliesFor(state: StructuredLaunchState): Map { - let replies = optionReplies.get(state) - if (!replies) { - replies = new Map() - optionReplies.set(state, replies) +const launchControllers = new WeakMap< + StructuredLaunchState, + ReturnType +>() +export const structuredLaunchSelectionController = (state: StructuredLaunchState) => { + let controller = launchControllers.get(state) + if (!controller) { + controller = structuredSessionSelectionController( + state.intent.target, + state.intent.sessionId, + state.intent.agent + ) + launchControllers.set(state, controller) } - return replies + return controller +} +const selectionController = structuredLaunchSelectionController +const receipts = new WeakMap() +/** Create and the attached view read the same finite operation overlay. */ +export function structuredLaunchOptionOverlay( + state: StructuredLaunchState +): Record { + return selectionController(state).overlay() } /** Hold picks only before publication; published/missing launches use the normal session option path. */ export function holdStructuredAgentSessionLaunchOption( sessionId: string, id: string, - encoded: string + encoded: string, + /** The picker's list, under which a model pick keeps only the held values the model offers. */ + picker?: StructuredAgentSessionOptionState ): Promise | null { const state = getStructuredLaunchStateBySessionId(sessionId) if (!state || state.cancelled || state.callers.outcome === 'published') { @@ -87,173 +99,166 @@ export function holdStructuredAgentSessionLaunchOption( if (state.selection.seedPending) { return null } - const replies = repliesFor(state) - const held: Record = {} - // Each held value is the chat's own: a pick supersedes an earlier pick of the same option. - for (const [key, value] of Object.entries(state.selection.held)) { - if (key !== id) { - held[key] = value - } else { - replies.get(key)?.({ kind: 'superseded' }) - replies.delete(key) - } + const model = picker + ? (structuredAgentSessionOptionPicks(picker, { [id]: encoded })[0]?.modelId ?? null) + : (state.selection.seed?.model ?? null) + const operation = selectionController(state).issue(id, encoded, model) + const receipt = receipts.get(state) + if (receipt) { + void applyStructuredLaunchHeldOptions(state, receipt, false) } - state.selection = { ...state.selection, held: { ...held, [id]: encoded } } - notifyStructuredLaunchListeners() - return new Promise((resolve) => replies.set(id, resolve)) + return operation.result } -async function setLaunchOption( +export async function setStructuredSessionSelectionOption( target: RuntimeClientTarget, sessionId: string, fence: number, key: string, - value: string + value: string, + options: { expectedEnvironmentPairingRevision?: number } = {} ): Promise { const fields = { key, value } try { const result = await callStructuredAgentSession< AgentSessionMutationResult - >(target, 'agentSession.setOption', { - envelope: { - sessionId, - clientOperationId: createStructuredAgentSessionOperationId(createBrowserUuid), - expectedRuntimeFence: fence, - payloadFingerprint: structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', + >( + target, + 'agentSession.setOption', + { + envelope: { sessionId, - fields - }) + clientOperationId: createStructuredAgentSessionOperationId(createBrowserUuid), + expectedRuntimeFence: fence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId, + fields + }) + }, + ...fields }, - ...fields - }) + options + ) return result.ok ? { kind: 'accepted', options: result.value.options ?? { [key]: value } } : { kind: 'refused', failure: agentSessionRefusalFailure(result.refusal) } } catch (error) { - return { - kind: 'refused', - failure: agentSessionThrownFailure( - error, - error instanceof RuntimeRpcCallError ? error.code : undefined - ) - } + const failure = agentSessionThrownFailure( + error, + error instanceof RuntimeRpcCallError ? error.code : undefined + ) + return failure.kind === 'unconfirmed' + ? { kind: 'unknown', failure } + : { kind: 'refused', failure } } } -function settleHeldOption( - state: StructuredLaunchState, - id: string, - encoded: string, - outcome: StructuredLaunchOptionOutcome -): void { - const { - held: { [id]: current, ...rest }, - seed - } = state.selection - // A pick made while this one was in flight superseded it and stays held for the next pass. - const settled = current === encoded - state.selection = { - explicit: state.selection.explicit, - seed: outcome.kind === 'accepted' ? { ...seed, ...outcome.options } : seed, - held: settled ? rest : state.selection.held - } - if (settled) { - const replies = repliesFor(state) - replies.get(id)?.(outcome) - replies.delete(id) - } - notifyStructuredLaunchListeners() - state.callers.onSettled() -} +const prerequisites = new WeakMap() -/** Old hosts configure the known chat before submitting; Fast keeps saved-preference admission. */ +/** One action decision per create attempt; picker settlement still belongs to its controller. */ export async function applyStructuredLaunchExplicitOptions( state: StructuredLaunchState, receipt: StructuredAgentLaunchReceipt ): Promise { - const requested = structuredLaunchRequestedOptions({ - explicit: state.selection.explicit, - held: state.selection.held - }) - if (!requested) { - return applyStructuredLaunchHeldOptions(state, receipt) - } - const compatible = structuredLaunchCompatibleOptions(state.intent.agent, requested) - const entries = Object.entries(compatible).sort(([a], [b]) => - a === 'model' ? -1 : b === 'model' ? 1 : 0 - ) - for (const [id, encoded] of entries) { - if (state.cancelled) { - throw new StructuredAgentSessionLaunchCancelledError() - } - if (id === 'fastMode' && state.selection.held[id] === undefined) { - continue - } - const outcome = await setLaunchOption( - state.intent.target, - receipt.sessionId, - receipt.fence, - id, - encoded - ) - settleHeldOption(state, id, encoded, outcome) - if (outcome.kind === 'refused' && requested[id] !== undefined) { - for (const [key, value] of Object.entries(state.selection.held)) { - settleHeldOption(state, key, value, outcome) + let phase = prerequisites.get(state) + if (!phase || phase.intent !== state.intent) { + const controller = selectionController(state) + const intent = state.intent + phase = createLegacyPrerequisites( + state, + controller, + (key, value) => + controller.request((options) => + setStructuredSessionSelectionOption( + intent.target, + receipt.sessionId, + receipt.fence, + key, + value, + options + ) + ), + () => { + void applyStructuredLaunchHeldOptions(state, receipt, false) } - return { ...receipt, configurationFailure: outcome.failure } - } + ) + prerequisites.set(state, phase) + receipts.set(state, receipt) + phase.advance?.() } - return applyStructuredLaunchHeldOptions(state, receipt) + const failure = await phase.decision + return failure ? { ...receipt, configurationFailure: failure } : receipt } -const createHeld = new WeakMap>>() +const createHeld = new WeakMap() -/** The held picks this create attempt carries, in whatever form it carries them. */ -export function holdStructuredLaunchCreatePicks( - state: StructuredLaunchState, - held: Readonly> -): void { - createHeld.set(state, held) +/** Freeze identities, so a later equal-valued choice is a distinct operation. */ +export function holdStructuredLaunchCreatePicks(state: StructuredLaunchState): void { + createHeld.set(state, selectionController(state).freeze(createHeld.get(state))) } -// Durable-create picks already count as host intent; settle carried values while retaining newer choices. export function settleStructuredLaunchCreateOptions( state: StructuredLaunchState, options: Readonly> ): void { - const carried = { ...options, ...createHeld.get(state) } - createHeld.delete(state) - for (const [id, encoded] of Object.entries(carried)) { - settleHeldOption(state, id, encoded, { kind: 'accepted', options }) + for (const operation of createHeld.get(state) ?? []) { + selectionController(state).settle(operation, { kind: 'accepted', options }) } + createHeld.delete(state) } -/** Apply held picks against the create fence until drained, model before effort. Report refusals to pickers without blocking launch publication. */ +/** All desktop senders enroll launch prerequisites before the actual operation request. */ +export function flushStructuredSessionSelectionOptions( + target: RuntimeClientTarget, + sessionId: string, + fence: number, + controller: ReturnType +): Promise | undefined { + const state = getStructuredLaunchStateBySessionId(sessionId) + const phase = + state && launchControllers.get(state) === controller ? prerequisites.get(state) : undefined + if (phase?.advance && phase.intent === state?.intent) { + phase.advance() + return phase.decision + } + controller.flush((operation) => + controller.request((options) => + setStructuredSessionSelectionOption( + target, + sessionId, + fence, + operation.key, + operation.value, + options + ) + ) + ) + return undefined +} + +/** Enqueue the current batch before attachment; later picks use this same ordered sender. */ export async function applyStructuredLaunchHeldOptions( state: StructuredLaunchState, - receipt: StructuredAgentLaunchReceipt + receipt: StructuredAgentLaunchReceipt, + waitForDrain = true ): Promise { - for (;;) { - if (state.cancelled) { - throw new StructuredAgentSessionLaunchCancelledError() + receipts.set(state, receipt) + const controller = selectionController(state) + const decision = flushStructuredSessionSelectionOptions( + state.intent.target, + receipt.sessionId, + receipt.fence, + controller + ) + if (waitForDrain) { + if (decision) { + const failure = await decision + return failure ? { ...receipt, configurationFailure: failure } : receipt } - const { held } = state.selection - const id = STRUCTURED_LAUNCH_OPTION_IDS.find((key) => held[key] !== undefined) - const encoded = id ? held[id] : undefined - if (!id || encoded === undefined) { - return receipt - } - const outcome = await setLaunchOption( - state.intent.target, - state.intent.sessionId, - receipt.fence, - id, - encoded - ) - settleHeldOption(state, id, encoded, outcome) + await controller.drain() } + return receipt } export function getStructuredAgentSessionLaunchSelection( diff --git a/src/renderer/src/lib/structured-agent-session-launch-prerequisites.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prerequisites.test.ts new file mode 100644 index 00000000000..fae9a17583d --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prerequisites.test.ts @@ -0,0 +1,402 @@ +// @vitest-environment happy-dom +import { + mocks, + resetCreateMessageFixture, + cleanupCreateMessageFixture, + deferred, + createParams, + created, + target, + draft, + legacyAction, + acceptedOption, + refusedOption, + optionCalls +} from './structured-agent-session-launch-create-message.test-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + holdStructuredAgentSessionLaunchOption, + structuredLaunchSelectionController, + flushStructuredSessionSelectionOptions +} from './structured-agent-session-launch-options' +import { + structuredSessionSelectionController, + forgetStructuredSessionSelection +} from './structured-agent-session-selection-controller' +import { + getStructuredLaunchStateBySessionId, + resetStructuredAgentLaunchRegistryForTests +} from './agent-session/launch/structured-agent-session-launch-registry' +import { + resetStructuredAgentLaunchPersistenceForTests, + readStructuredAgentLaunchRecord +} from './agent-session/launch/structured-agent-session-launch-persistence' +import { getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { recoverPublishedSessionReceipt } from './agent-session/launch/structured-agent-session-launch-recovery' +import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message' +import { + retryStructuredAgentSessionLaunch, + getStructuredAgentSessionLaunchLifecycle +} from './structured-agent-session-launch' + +let restoreCatalogStore: (() => void) | undefined +describe('legacy opening action prerequisites through real launch orchestration', () => { + beforeEach(() => { + restoreCatalogStore = resetCreateMessageFixture() + }) + afterEach(() => { + cleanupCreateMessageFixture(restoreCatalogStore) + }) + + it.each([ + ['model', 'picked-model'], + ['effort', 'low'], + ['serviceTier', 'priority'], + ['fastMode', 'true'], + ['permissionMode', 'full-access'] + ])( + 'returns required held %s failure through actual publication and prompt', + async (key, value) => { + const unrelated = deferred() + const { launch, create } = legacyAction( + { model: 'action-model', effort: 'high' }, + async (field, encoded) => + field === key + ? refusedOption + : key === 'model' && field === 'permissionMode' + ? unrelated.promise + : acceptedOption(field, encoded), + 'local', + key === 'fastMode' ? 'claude' : 'codex' + ) + const preference = + key === 'model' + ? holdStructuredAgentSessionLaunchOption(launch.sessionId, 'permissionMode', 'ask') + : null + const held = holdStructuredAgentSessionLaunchOption(launch.sessionId, key, value)! + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await expect(held).resolves.toMatchObject({ kind: 'refused' }) + await expect(launch.launchResult).resolves.toHaveProperty('configurationFailure') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true + }) + expect(draft(launch.sessionId)).toBe('opening text') + expect(getStructuredAgentSessionSendNotice(launch.sessionId)).toContain( + key === 'fastMode' ? 'Claude' : 'Codex' + ) + expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.send')).toBe(false) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(false) + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + expect(optionCalls().filter((call) => call.key === key)).toHaveLength(1) + if (key === 'effort') { + expect(optionCalls().map((call) => call.key)).toEqual(['model', 'effort']) + } + if (preference) { + expect(optionCalls().filter((call) => call.key === 'permissionMode')).toHaveLength(1) + unrelated.resolve(acceptedOption('permissionMode', 'ask')) + await expect(preference).resolves.toMatchObject({ kind: 'accepted' }) + } + } + ) + + it.each([false, true])( + 'keeps held unknown nonblocking only without explicit action (%s)', + async (explicit) => { + const { launch, create } = legacyAction( + explicit ? { model: 'action-model', effort: 'high' } : undefined, + async () => { + throw new Error('runtime_unavailable') + } + ) + const held = holdStructuredAgentSessionLaunchOption( + launch.sessionId, + 'model', + 'picked-model' + )! + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await expect(held).resolves.toMatchObject({ + kind: 'unknown', + failure: { kind: 'unconfirmed' } + }) + const receipt = await launch.launchResult + expect(receipt.configurationFailure !== undefined).toBe(explicit) + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: !explicit }) + expect(optionCalls()).toHaveLength(1) + } + ) + + it('rederives unsent dependents from retained exact handles and enrolls a later visited field', async () => { + const model = deferred() + const effort = deferred() + const writes: { key: string; value: string }[] = [] + const { launch, create } = legacyAction( + { model: 'B', effort: 'high', serviceTier: 'default' }, + async (key, value) => { + writes.push({ key, value }) + return writes.length === 1 + ? model.promise + : key === 'effort' + ? effort.promise + : acceptedOption(key, value) + } + ) + const obsolete = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')! + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'C') + const newest = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')! + await expect(obsolete).resolves.toEqual({ kind: 'superseded' }) + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await vi.waitFor(() => expect(writes).toEqual([{ key: 'model', value: 'B' }])) + const owner = structuredLaunchSelectionController( + getStructuredLaunchStateBySessionId(launch.sessionId)! + ) + const first = owner.current('model')! + expect(first.transmitted).toBe(true) + // A current query may retire the picker object before the action continuation. + const stopRead = owner.subscribe(() => { + const terminal = owner.captureRead() + if (terminal.length) { + stopRead() + owner.retire(terminal) + } + }) + const later = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'C')! + await expect(later).resolves.toMatchObject({ kind: 'accepted' }) + expect(owner.current('model')).toBeUndefined() + model.resolve(acceptedOption('model', 'B')) + await expect(newest).resolves.toMatchObject({ kind: 'accepted' }) + await vi.waitFor(() => expect(writes).toHaveLength(3)) + expect(writes[2]).toEqual({ key: 'effort', value: 'medium' }) + effort.resolve(acceptedOption('effort', 'medium')) + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(writes.map(({ key, value }) => [key, value])).toEqual([ + ['model', 'B'], + ['model', 'C'], + ['effort', 'medium'] + ]) + }) + + it('preserves transmitted required failure after newer success and joins actual recovered reentry', async () => { + const older = deferred() + const { launch, create } = legacyAction( + { model: 'action-model', effort: 'high' }, + async (key, value) => (value === 'B' ? older.promise : acceptedOption(key, value)) + ) + const b = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B')! + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await vi.waitFor(() => expect(optionCalls()).toHaveLength(1)) + const state = getStructuredLaunchStateBySessionId(launch.sessionId)! + const c = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'C')! + await expect(c).resolves.toMatchObject({ kind: 'accepted' }) + const original = state.intent + // Drive the real recovery→publication chain with its legitimate successor receipt. + const recovered = recoverPublishedSessionReceipt(state).then((receipt) => + publishStructuredLaunchCreateOptions(state, receipt) + ) + await vi.waitFor(() => + expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.history')).toBe( + true + ) + ) + expect(state.intent).toBe(original) + older.resolve(refusedOption) + await expect(b).resolves.toMatchObject({ kind: 'refused' }) + await expect(launch.launchResult).resolves.toHaveProperty('configurationFailure') + await expect(recovered).resolves.toMatchObject({ fence: 2, configurationFailure: {} }) + await expect( + recoverPublishedSessionReceipt(state).then((receipt) => + publishStructuredLaunchCreateOptions(state, receipt) + ) + ).resolves.toMatchObject({ fence: 2, configurationFailure: {} }) + expect(optionCalls().map(({ value }) => value)).toEqual(['B', 'C']) + expect(draft(launch.sessionId)).toBe('opening text') + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send') + ).toHaveLength(0) + }) + + it('enrolls actual attached-owner requests before dispatch and hands unrelated picker work off on failure', async () => { + const model = deferred() + const { launch, create } = legacyAction( + { model: 'action-model', effort: 'high' }, + (key, value) => + key === 'model' + ? model.promise + : key === 'permissionMode' + ? Promise.resolve(refusedOption) + : Promise.resolve(acceptedOption(key, value)) + ) + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await vi.waitFor(() => expect(optionCalls()).toHaveLength(1)) + const owner = structuredLaunchSelectionController( + getStructuredLaunchStateBySessionId(launch.sessionId)! + ) + const pick = owner.issue('permissionMode', 'full-access', 'action-model') + void flushStructuredSessionSelectionOptions(target, launch.sessionId, 1, owner) + model.resolve(acceptedOption('model', 'action-model')) + await expect(pick.result).resolves.toMatchObject({ kind: 'refused' }) + await expect(launch.launchResult).resolves.toHaveProperty('configurationFailure') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true + }) + expect(optionCalls().filter((call) => call.key === 'permissionMode')).toHaveLength(1) + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send') + ).toHaveLength(0) + }) + + it('returns the real keyless original-owner failure and one user Retry never rebinds option writes', async () => { + const paired = { kind: 'environment', environmentId: 'legacy-original' } as const + replaceRuntimeEnvironmentRevisions([{ id: paired.environmentId, createdAt: 1 }]) + const raw = deferred() + const { launch, create } = legacyAction( + { model: 'action-model' }, + () => raw.promise, + 'runtime:legacy-original' + ) + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await vi.waitFor(() => expect(optionCalls()).toHaveLength(1)) + const state = getStructuredLaunchStateBySessionId(launch.sessionId)! + const original = state.intent + const owner = structuredLaunchSelectionController(state) + replaceRuntimeEnvironmentRevisions([{ id: paired.environmentId, createdAt: 2 }]) + await expect(launch.launchResult).rejects.toThrow('owner retired') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) + expect(draft(launch.sessionId)).toBe('opening text') + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe('failed') + raw.resolve(acceptedOption('model', 'action-model')) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await expect(state.promise).rejects.toThrow('owner retired') + expect(state.intent).not.toBe(original) + expect(structuredLaunchSelectionController(state)).toBe(owner) + expect(optionCalls()).toHaveLength(1) + const targets = mocks.call.mock.calls.map(([host, method]) => ({ host, method })) + expect(targets).toEqual([ + { host: paired, method: 'agentSession.create' }, + { host: paired, method: 'agentSession.setOption' }, + { host: paired, method: 'agentSession.create' } + ]) + expect(mocks.admit).toHaveBeenCalledTimes(2) + expect(draft(launch.sessionId)).toBe('opening text') + forgetStructuredSessionSelection(launch.sessionId, paired) + replaceRuntimeEnvironmentRevisions([]) + }) + + it('bounds real late enrollment work and releases its scoped observation at N and 10N', async () => { + const work: number[] = [] + for (const count of [10, 100]) { + const waiting = deferred() + const { launch, create } = legacyAction({ model: 'action-model' }, (key, value) => + key === 'model' ? waiting.promise : Promise.resolve(acceptedOption(key, value)) + ) + const state = getStructuredLaunchStateBySessionId(launch.sessionId)! + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'action-model') + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', 'high') + const owner = structuredLaunchSelectionController(state) + let subscriptions = 0 + const subscribe = owner.subscribe + const observation = vi.spyOn(owner, 'subscribe').mockImplementation((listener) => { + subscriptions++ + const stop = subscribe(listener) + return () => { + subscriptions-- + stop() + } + }) + let visits = 0 + const issue = owner.issue + const access = vi.spyOn(owner, 'issue').mockImplementation((...args) => { + const operation = issue(...args) + for (const key of ['identity', 'key', 'outcome', 'transmitted'] as const) { + let value = operation[key] + Object.defineProperty(operation, key, { + configurable: true, + get() { + visits++ + return value + }, + set(next) { + value = next + } + }) + } + return operation + }) + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await vi.waitFor(() => expect(optionCalls()).toHaveLength(2)) + for (let index = 0; index < count; index++) { + const before = visits + const pick = holdStructuredAgentSessionLaunchOption( + launch.sessionId, + 'permissionMode', + index % 2 ? 'ask' : 'full-access' + )! + await expect(pick).resolves.toMatchObject({ kind: 'accepted' }) + expect(visits - before).toBeLessThanOrEqual(80) + } + waiting.resolve(acceptedOption('model', 'action-model')) + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(subscriptions).toBe(0) + expect(owner.pending()).toEqual([]) + expect(optionCalls().filter((call) => call.key === 'permissionMode')).toHaveLength(count) + work.push(visits) + observation.mockRestore() + access.mockRestore() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + forgetStructuredSessionSelection(launch.sessionId) + mocks.call.mockClear() + } + console.info( + 'legacy-phase-property-work', + JSON.stringify({ populations: [10, 100], visits: work }) + ) + expect(work[1]).toBeLessThanOrEqual(work[0] * 10) + }) + + it.each([false, true])( + 'releases never-returning %s required requests at true end without reviving equal-text owners', + async (held) => { + const raw = deferred() + const { launch, create } = legacyAction({ model: 'action-model' }, () => raw.promise) + if (held) { + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'B') + } + await vi.waitFor(() => expect(createParams()).toBeDefined()) + create.resolve(created()) + await vi.waitFor(() => expect(optionCalls()).toHaveLength(1)) + const owner = structuredLaunchSelectionController( + getStructuredLaunchStateBySessionId(launch.sessionId)! + ) + const other = structuredSessionSelectionController( + { kind: 'environment', environmentId: 'other-legacy' }, + launch.sessionId, + 'codex' + ) + other.issue('model', 'other', null) + forgetStructuredSessionSelection(launch.sessionId, target) + await expect(launch.launchResult).rejects.toThrow('owner retired') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) + expect(draft(launch.sessionId)).toBe('opening text') + const replacement = structuredSessionSelectionController(target, launch.sessionId, 'codex') + expect(replacement).not.toBe(owner) + expect(other.overlay()).toEqual({ model: 'other' }) + raw.resolve(acceptedOption('model', 'B')) + await Promise.resolve() + expect(replacement.overlay()).toEqual({}) + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send') + ).toHaveLength(0) + forgetStructuredSessionSelection(launch.sessionId) + } + ) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prerequisites.ts b/src/renderer/src/lib/structured-agent-session-launch-prerequisites.ts new file mode 100644 index 00000000000..b2d5513feee --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prerequisites.ts @@ -0,0 +1,137 @@ +import type { + SelectionOperation, + SelectionOutcome +} from '../../../shared/structured-agent-session-selection-operations' +import type { AgentSessionWriteFailure } from '../../../shared/agent-session-write-failure' +import { + STRUCTURED_LAUNCH_OPTION_IDS, + structuredLaunchCompatibleOptions, + structuredLaunchRequestedOptions +} from '../../../shared/native-chat/input/native-chat-session-option-defaults' +import type { structuredSessionSelectionController } from './structured-agent-session-selection-controller' +import type { StructuredLaunchState } from './agent-session/launch/structured-agent-session-launch-registry' +import { StructuredAgentSessionLaunchCancelledError } from './agent-session/launch/structured-agent-session-launch-recovery' + +export type LegacyPrerequisites = { + intent: StructuredLaunchState['intent'] + decision: Promise + advance?: () => void +} + +export function createLegacyPrerequisites( + state: StructuredLaunchState, + controller: ReturnType, + send: (key: string, value: string) => Promise, + handoff: () => void +): LegacyPrerequisites { + const result = Promise.withResolvers() + const active: LegacyPrerequisites = { intent: state.intent, decision: result.promise } + const explicit = state.selection.explicit + const context = new Map() + const direct = new Set() + const enrolled = new Set() + let pending = 0 + let release = () => {} + const finish = (failure?: AgentSessionWriteFailure, error?: unknown) => { + if (!active.advance) { + return + } + active.advance = undefined + release() + context.clear() + enrolled.clear() + if (error !== undefined) { + result.reject(error) + } else { + // Remaining picker work has its own result and must not delay failed action handback. + if (failure) { + handoff() + } + result.resolve(failure) + } + } + const perform = (key: string, value: string) => { + if (state.cancelled) { + throw new StructuredAgentSessionLaunchCancelledError() + } + return send(key, value) + } + const enroll = ( + key: string, + value: string, + required: boolean, + operation?: SelectionOperation + ) => { + pending++ + if (operation) { + enrolled.add(operation) + } + const request = operation + ? controller.transmit(operation, () => perform(key, value)) + : (async () => perform(key, value))() + void request.then( + (outcome) => { + pending-- + if (!active.advance) { + return + } + if (!controller.isCurrent()) { + finish(undefined, new Error('Conversation selection owner retired.')) + } else if ( + required && + (outcome.kind === 'refused' || outcome.kind === 'unknown') && + outcome.failure + ) { + finish(outcome.failure) + } else { + active.advance() + } + }, + (error: unknown) => finish(undefined, error) + ) + } + active.advance = () => { + if (!controller.isCurrent() || state.cancelled) { + finish( + undefined, + state.cancelled + ? new StructuredAgentSessionLaunchCancelledError() + : new Error('Conversation selection owner retired.') + ) + return + } + for (const key of STRUCTURED_LAUNCH_OPTION_IDS) { + const operation = controller.current(key) + if (operation) { + context.set(key, operation) + } + } + const held = Object.fromEntries([...context].map(([key, operation]) => [key, operation.value])) + const requested = structuredLaunchRequestedOptions({ explicit, held }) + const compatible = requested && structuredLaunchCompatibleOptions(state.intent.agent, requested) + for (const key of STRUCTURED_LAUNCH_OPTION_IDS) { + const operation = context.get(key) + if (operation && !operation.outcome && !enrolled.has(operation)) { + enroll(key, operation.value, requested?.[key] !== undefined, operation) + } + const value = compatible?.[key] + if (value !== undefined && !context.has(key) && !direct.has(key) && key !== 'fastMode') { + if (!pending) { + direct.add(key) + enroll(key, value, requested?.[key] !== undefined) + } + return + } + } + if (pending) { + return + } + finish() + } + release = controller.subscribe(() => { + if (!controller.isCurrent()) { + active.advance?.() + } + }) + return active +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index 29ef26bad28..a70bb9d290c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type * as RecoveryModule from '@/lib/agent-session/launch/structured-agent-session-launch-recovery' @@ -81,6 +81,7 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ + runtimeEnvironmentCatalogHydrated: true, unifiedTabsByWorktree: mocks.rendererTabs, seedNativeChatLaunchDraft: mocks.seedDraft, clearNativeChatLaunchDraft: mocks.clearDraft @@ -127,6 +128,17 @@ import { resetStructuredAgentLaunchPersistenceForTests } from './agent-session/l import { resetStructuredAgentLaunchRegistryForTests } from './agent-session/launch/structured-agent-session-launch-registry' import { getStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options' +import { useAppStore } from '@/store' +import { registerStructuredSelectionCatalogStore } from './structured-agent-session-selection-controller' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' + +let restoreCatalog: () => void +afterEach(() => { + resetStructuredAgentLaunchRegistryForTests() + replaceRuntimeEnvironmentRevisions([]) + restoreCatalog() +}) + function launchIntent( worktreeId: string, sessionId = `session-${worktreeId}` @@ -171,6 +183,12 @@ function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobile } } +function publishIntent(intent: StructuredAgentSessionLaunchIntent): void { + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(intent.worktreeId, intent.sessionId) + ]) +} + function sends(): unknown[][] { return mocks.callRuntimeRpc.mock.calls.filter((call) => call[1] === 'agentSession.send') } @@ -187,7 +205,12 @@ async function flushLaunchSettlement(): Promise { describe('startStructuredAgentLaunch', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() + restoreCatalog = registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe: useAppStore.subscribe + }) + replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 1, pairingRevision: 1 }]) localStorage.clear() resetStructuredAgentLaunchPersistenceForTests() resetStructuredAgentLaunchRegistryForTests() @@ -224,9 +247,7 @@ describe('startStructuredAgentLaunch', () => { const intent = launchIntent(worktreeId, 'draft-session') mocks.createIntent.mockReturnValueOnce(intent) mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) const launch = startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-1', @@ -251,9 +272,7 @@ describe('startStructuredAgentLaunch', () => { const intent = launchIntent(worktreeId, 'long-draft-session') mocks.createIntent.mockReturnValueOnce(intent) mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) const sixtyLineDraft = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join('\n') const launch = startStructuredAgentLaunch(worktreeId, 'codex', { @@ -341,9 +360,7 @@ describe('startStructuredAgentLaunch', () => { const intent = launchIntent(worktreeId, 'session-1') mocks.createIntent.mockReturnValueOnce(intent) mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) mocks.callStructuredAgentSession.mockResolvedValue({ ok: true, value: { submission: { dispatchState: 'accepted' } } @@ -467,9 +484,7 @@ describe('startStructuredAgentLaunch', () => { () => new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve)) ) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) mocks.callStructuredAgentSession.mockResolvedValue({ ok: true, value: { submission: { dispatchState: 'accepted' } } @@ -494,9 +509,7 @@ describe('startStructuredAgentLaunch', () => { () => new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve)) ) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) const first = startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'double-click', @@ -537,9 +550,7 @@ describe('startStructuredAgentLaunch', () => { const pendingSends: ((result: unknown) => void)[] = [] mocks.createIntent.mockReturnValue(intent) mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve))) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) mocks.callRuntimeRpc.mockImplementation( () => new Promise((resolve) => pendingSends.push(resolve)) ) @@ -605,9 +616,7 @@ describe('startStructuredAgentLaunch', () => { const intent = launchIntent(worktreeId) mocks.createIntent.mockReturnValueOnce(intent) mocks.launch.mockRejectedValueOnce(new Error('response lost')) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-20' }) await flushLaunchSettlement() @@ -673,9 +682,7 @@ describe('startStructuredAgentLaunch', () => { startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-23', resumeFrom }) await flushLaunchSettlement() - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-24', resumeFrom }) await flushLaunchSettlement() @@ -698,9 +705,7 @@ describe('startStructuredAgentLaunch', () => { await expect(first.launchResult).rejects.toThrow('offline') expect(first.releaseCallerAfterUnknownOutcome()).toBe(true) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) // The chat's own Retry re-checks it; a new start would open a new chat instead. expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true) await flushLaunchSettlement() @@ -749,9 +754,7 @@ describe('startStructuredAgentLaunch', () => { mocks.launch .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) .mockResolvedValueOnce({ sessionId: first.sessionId, fence: 1 }) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, first.sessionId) - ]) + publishIntent(first) startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-27' }) await flushLaunchSettlement() @@ -813,9 +816,7 @@ describe('startStructuredAgentLaunch', () => { mocks.launch .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) .mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 }) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ - publishedSnapshot(worktreeId, intent.sessionId) - ]) + publishIntent(intent) startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-29', diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 0b72ba3203f..f9eb24141b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -49,7 +49,10 @@ import { trackStructuredLaunchConfirmationDeadline } from './agent-session/launch/structured-agent-session-launch-prompt-outcome' import { repeatedStructuredLaunchAttempt } from './structured-agent-session-launch-request' -import { initialStructuredLaunchSelection } from './structured-agent-session-launch-options' +import { + initialStructuredLaunchSelection, + structuredLaunchSelectionController +} from './structured-agent-session-launch-options' import { releaseStructuredLaunchSource } from './structured-agent-session-launch-source-handoff' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } @@ -143,11 +146,9 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { resetStructuredLaunchCallers(state) delete state.failure state.callers.outcome = 'pending' - // New attempts use current local/paired create settings while preserving held picks through failure. - state.selection = { - ...initialStructuredLaunchSelection(state.intent), - held: state.selection.held - } + // A new create seeds from the settings of now (a paired server's arrive with its probe); picks + // held through the failure still apply. + state.selection = initialStructuredLaunchSelection(state.intent) state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) state.promise = publishWithHeldOptions( state, @@ -244,6 +245,7 @@ function structuredAgentLaunchState( } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) + structuredLaunchSelectionController(state) setStructuredLaunchState(state) reserveStructuredLaunchSlot(state.intent) state.promise = publishWithHeldOptions(state, launchAndReconcile(state)) @@ -258,11 +260,7 @@ function structuredAgentLaunchState( setStructuredLaunchState(state) notifyStructuredLaunchListeners() trackLaunchSettlement(state, state.promise) - return { - state, - caller, - joined: false - } + return { state, caller, joined: false } } export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: string): boolean { diff --git a/src/renderer/src/lib/structured-agent-session-selection-controller.ts b/src/renderer/src/lib/structured-agent-session-selection-controller.ts new file mode 100644 index 00000000000..264feff3f3d --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-selection-controller.ts @@ -0,0 +1,179 @@ +import { + onRuntimeEnvironmentsRetired, + onRuntimeEnvironmentRevisionsChanged, + isRuntimeEnvironmentContinuationPending, + getRuntimeEnvironmentRevision, + resolveContinuedRuntimeEnvironmentRevision +} from '@/runtime/runtime-environment-revision' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import { + createStructuredSessionSelectionController, + type StructuredSessionSelectionController +} from '../../../shared/structured-agent-session-selection-operations' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { AgentType } from '../../../shared/agent-status-types' +import { enqueueSessionOptionSettingsWrite } from '@/components/native-chat/native-chat-session-option-settings-write' + +type SelectionCatalogState = { runtimeEnvironmentCatalogHydrated: boolean } +type SelectionCatalogStore = { + read: () => SelectionCatalogState + subscribe: (listener: (state: SelectionCatalogState) => void) => () => void +} +let catalogStore: SelectionCatalogStore | undefined + +// Cleanup imports from store slices must not construct the app store. +export function registerStructuredSelectionCatalogStore(store: SelectionCatalogStore): () => void { + const previous = catalogStore + catalogStore = store + return () => { + catalogStore = previous + } +} + +export function structuredSessionSelectionAuthority(target: RuntimeClientTarget): string { + return target.kind === 'local' + ? 'local' + : JSON.stringify([ + getRuntimeEnvironmentRevision(target.environmentId), + getRuntimeEnvironmentConnectionGeneration(target.environmentId) + ]) +} +type SelectionRequestOptions = { expectedEnvironmentPairingRevision?: number } +type SessionSelectionController = StructuredSessionSelectionController & { + isCurrent(): boolean + request(perform: (options: SelectionRequestOptions) => Promise): Promise +} +const controllers = new Map< + string, + { + sessionId: string + host: string + controller: SessionSelectionController + originalRevision: () => number | undefined + release: () => void + } +>() + +/** Launch and attached views share the same sender on the execution host's logical chat. */ +export function structuredSessionSelectionController( + target: RuntimeClientTarget, + sessionId: string, + agent: AgentType +) { + const host = target.kind === 'local' ? 'local' : target.environmentId + const key = JSON.stringify([host, sessionId, agent]) + const existing = controllers.get(key) + if (existing) { + return existing.controller + } + const store = target.kind === 'environment' ? catalogStore : undefined + if (target.kind === 'environment' && !store) { + throw new Error('Selection catalog store must be registered before remote acquisition.') + } + let originalRevision = + target.kind === 'environment' ? getRuntimeEnvironmentRevision(target.environmentId) : undefined + const isCurrent = () => controllers.get(key)?.controller === controller + const controller: SessionSelectionController = { + ...createStructuredSessionSelectionController((picks) => { + void controller + .request((options) => { + const mutation = { type: 'apply-picks', agent, picks } as const + return options.expectedEnvironmentPairingRevision === undefined + ? enqueueSessionOptionSettingsWrite(target, mutation) + : enqueueSessionOptionSettingsWrite(target, mutation, options) + }) + .catch(() => undefined) + }, isCurrent), + isCurrent, + async request(perform) { + if (!isCurrent()) { + throw new Error('Conversation selection owner retired.') + } + // An absent original pairing cannot confer authority over a later registration. + if (target.kind === 'environment' && originalRevision === undefined) { + throw new Error('Conversation host pairing unavailable.') + } + const result = await perform( + originalRevision === undefined + ? {} + : { + expectedEnvironmentPairingRevision: resolveContinuedRuntimeEnvironmentRevision( + host, + originalRevision + ) + } + ) + if (!isCurrent()) { + throw new Error('Conversation selection owner retired.') + } + return result + } + } + let release = () => {} + controllers.set(key, { + sessionId, + host, + controller, + originalRevision: () => originalRevision, + release: () => release() + }) + if (store && !store.read().runtimeEnvironmentCatalogHydrated) { + release = store.subscribe((state) => { + if (state.runtimeEnvironmentCatalogHydrated) { + originalRevision = getRuntimeEnvironmentRevision(host) + release() + } + }) + } + return controller +} + +function endSelection(key: string): void { + const entry = controllers.get(key) + if (entry) { + controllers.delete(key) + entry.release() + entry.controller.abandon() + } +} + +/** Closing/purging a chat explicitly ends its transient operations, including ambiguous writes. */ +export function forgetStructuredSessionSelection( + sessionId: string, + target?: RuntimeClientTarget +): void { + const host = target?.kind === 'local' ? 'local' : target?.environmentId + for (const [key, entry] of controllers) { + if (entry.sessionId === sessionId && (host === undefined || entry.host === host)) { + endSelection(key) + } + } +} + +const retireHosts = (ids: readonly string[]) => { + for (const [key, entry] of controllers) { + if (ids.includes(entry.host)) { + endSelection(key) + } + } +} +onRuntimeEnvironmentsRetired(retireHosts) + +onRuntimeEnvironmentRevisionsChanged((ids) => { + for (const [key, entry] of controllers) { + if (!ids.includes(entry.host)) { + continue + } + const original = entry.originalRevision() + const continued = + original === undefined + ? undefined + : resolveContinuedRuntimeEnvironmentRevision(entry.host, original) + if ( + continued !== getRuntimeEnvironmentRevision(entry.host) && + !isRuntimeEnvironmentContinuationPending(entry.host, continued) + ) { + endSelection(key) + } + } +}) 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 new file mode 100644 index 00000000000..6148fec18f4 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx @@ -0,0 +1,539 @@ +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import type { StructuredAgentSessionMutate } from '@/components/native-chat/use-structured-agent-session-mutate' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { useAppStore } from '@/store' +import { useStructuredAgentSessionOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' +import { structuredAgentSessionSeedCatalog } from '../../../shared/structured-agent-session-seed-catalog' +import { structuredAgentSessionMessageSelection } from '../../../shared/structured-agent-session-option-view' +import { structuredAgentSessionModelId } from '../../../shared/structured-agent-session-option-state' +import { startStructuredAgentLaunch } from './structured-agent-session-launch' +import { + holdStructuredAgentSessionLaunchOption, + structuredLaunchSelectionController +} from './structured-agent-session-launch-options' +import { + getStructuredLaunchStateBySessionId, + resetStructuredAgentLaunchRegistryForTests +} from './agent-session/launch/structured-agent-session-launch-registry' +import { useStructuredAgentSessionOptions } from '@/components/native-chat/use-structured-agent-session-options' +import { listRuntimeSessionHostsForStartup } from '@/app-shell/startup-runtime-session-hosts' +import { structuredAgentSessionTargetForTab } from '@/runtime/structured-agent-session-owner' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { + registerStructuredSelectionCatalogStore, + forgetStructuredSessionSelection, + structuredSessionSelectionController +} from './structured-agent-session-selection-controller' + +const unavailableMutation: StructuredAgentSessionMutate = async () => null + +const transport = vi.hoisted(() => ({ + create: vi.fn(), + intent: vi.fn(), + call: vi.fn(), + remember: vi.fn() +})) +vi.mock('./launch-structured-agent-session', () => ({ + createStructuredAgentSessionLaunchIntent: transport.intent, + abandonStructuredAgentSessionLaunchIntent: vi.fn() +})) +vi.mock('./structured-agent-session-launch-create-call', () => ({ + launchStructuredAgentSession: transport.create +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: transport.call +})) +vi.mock('@/components/native-chat/StructuredAgentSessionStatusBridge', () => ({ + useStructuredAgentSessionStatusSummary: () => ({ summary: undefined, observation: 0 }) +})) +vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ + useStructuredAgentSessionHostCapability: () => true, + useStructuredAgentSessionOwnPermissionState: () => 'supported', + structuredAgentSessionHostKey: () => 'paired-host' +})) +vi.mock('@/components/native-chat/native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: transport.remember +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() } })) + +const target = { kind: 'environment', environmentId: 'paired-host' } as const +const sessionId = 'codex_reconnect' +const initial = useAppStore.getState() +const catalog = structuredAgentSessionSeedCatalog('codex') +const seed = { model: 'gpt-5.5' } +function publish(runtimeId: string, sequence: number) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the production status slice reads runtime identity and protocol fields here. + const status = { + runtimeId, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3 + } as RuntimeStatus + useAppStore.getState().setRuntimeEnvironmentStatus( + target.environmentId, + { + status, + checkedAt: sequence, + snapshot: { + environmentId: target.environmentId, + pairingRevision: 1, + sequence, + checkedAt: sequence, + status, + verification: 'verified', + transport: 'ready' + } + }, + { suppressDisconnectToast: true } + ) +} +afterEach(() => { + cleanup() + forgetStructuredSessionSelection(sessionId) + resetStructuredAgentLaunchRegistryForTests() + useAppStore.setState(initial, true) + vi.clearAllMocks() +}) + +it.each([false, true])( + 'keeps launch, hook, drawn and captured operations through the real runtime transition (newer pick=%s)', + async (newer) => { + const intent: StructuredAgentSessionLaunchIntent = { + worktreeId: 'wt-lifetime', + executionHostId: 'runtime:paired-host', + target, + sessionId, + agent: 'codex', + seedOptions: seed, + params: { + agent: 'codex', + worktree: 'id:wt-lifetime', + envelope: { + sessionId, + clientOperationId: 'original-create', + payloadFingerprint: 'frozen', + expectedRuntimeFence: null + } + } + } + transport.intent.mockReturnValue(intent) + const create = Promise.withResolvers<{ sessionId: string; fence: number }>() + transport.create.mockReturnValue(create.promise) + const replies = [Promise.withResolvers(), Promise.withResolvers()] + const reads: ReturnType>[] = [] + let sends = 0 + transport.call.mockImplementation((_target, method) => { + if (method === 'agentSession.options') { + const read = Promise.withResolvers() + reads.push(read) + return read.promise + } + if (method === 'agentSession.setOption') { + return replies[sends++].promise + } + return Promise.resolve({}) + }) + useAppStore.getState().setRuntimeEnvironments([environment(1)]) + publish('runtime-A', 1) + const launch = startStructuredAgentLaunch('wt-lifetime', 'codex', { + requestId: 'lifetime', + executionHostId: 'runtime:paired-host' + }) + const state = getStructuredLaunchStateBySessionId(sessionId)! + const owner = structuredLaunchSelectionController(state) + let original: ReturnType + act(() => { + original = holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-5.4') + }) + const hook = renderHook( + ({ visible }) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId, + target, + fence: visible ? 7 : null, + launch: { kind: 'new', seedOptions: seed }, + isVisible: false, + transportEnabled: true, + providerVisible: visible, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: unavailableMutation + }), + { initialProps: { visible: false } } + ) + const readHook = renderHook(() => + useStructuredAgentSessionOptionState({ + agent: 'codex', + optionCatalog: catalog, + identity: `codex:${sessionId}`, + fence: 1, + sessionId, + target, + newLaunch: true, + seedsModel: true, + providerVisible: true, + providerStarting: false, + hostOptionStamp: '', + readsBeforeStart: true, + turnId: null, + unloadedTurnRevisions: undefined + }) + ) + expect(reads).toHaveLength(1) + const capture = () => { + const source = hook.result.current.sendSource + const view = source.optionView(source.optionStateRef.current, seed) + return { + drawn: structuredAgentSessionModelId(view.record, 'own'), + selection: structuredAgentSessionMessageSelection(view) + } + } + let settled = false + void original!.then(() => { + settled = true + }) + const retainedOwners = new Set([owner]) + for (let generation = 2; generation <= (newer ? 100 : 10); generation++) { + act(() => publish(`runtime-${generation}`, generation)) + retainedOwners.add(structuredSessionSelectionController(target, sessionId, 'codex')) + retainedOwners.add(structuredLaunchSelectionController(state)) + } + expect(retainedOwners.size).toBe(1) + await act(async () => reads[0].resolve({ models: [], current: { model: 'stale-generation' } })) + expect(readHook.result.current.optionState.optionsAnswerPresent).not.toBe(true) + readHook.unmount() + reads.length = 0 + expect(structuredSessionSelectionController(target, sessionId, 'codex')).toBe(owner) + expect(structuredLaunchSelectionController(state)).toBe(owner) + expect(settled).toBe(false) + expect(capture()).toMatchObject({ drawn: 'gpt-5.4', selection: { model: 'gpt-5.4' } }) + let chosen = original! + if (newer) { + act(() => { + chosen = holdStructuredAgentSessionLaunchOption(sessionId, 'model', 'gpt-5.3-codex')! + }) + await expect(original!).resolves.toEqual({ kind: 'superseded' }) + } + const model = newer ? 'gpt-5.3-codex' : 'gpt-5.4' + expect(capture()).toMatchObject({ drawn: model, selection: { model } }) + await act(async () => create.resolve({ sessionId, fence: 7 })) + expect(sends).toBe(1) + hook.rerender({ visible: true }) + await waitFor(() => expect(reads).toHaveLength(1)) + let attached: Promise | undefined + if (newer) { + act(() => { + attached = hook.result.current.setStructuredOption('effort', 'high') + }) + expect(sends).toBe(2) + expect(capture().selection).toMatchObject({ model, effort: 'high' }) + await act(async () => + replies[1].resolve({ ok: true, value: { options: { model, effort: 'high' } } }) + ) + await expect(attached).resolves.toBe(true) + expect(transport.remember.mock.calls[0][1]).toMatchObject({ + picks: [{ modelId: model, optionId: 'effort', value: 'high' }] + }) + } + // This query predates acceptance and cannot acknowledge its later terminal identity. + await act(async () => replies[0].resolve({ ok: true, value: { options: { model } } })) + await expect(chosen).resolves.toMatchObject({ kind: 'accepted' }) + await launch.launchResult + const completed = owner.captureRead() + expect(completed).toHaveLength(newer ? 2 : 1) + expect(transport.remember).toHaveBeenCalledTimes(newer ? 2 : 1) + if (newer) { + expect(transport.remember.mock.calls[1][1]).toMatchObject({ + picks: [ + { modelId: model, optionId: 'model', value: model, adoptModelAsLaunchDefault: false } + ] + }) + } + expect(hook.result.current.sendSource.optionStateRef.current.optionsAnswerPresent).not.toBe( + true + ) + await act(async () => reads[0].resolve({ models: [], current: { model: 'other-client' } })) + await waitFor(() => expect(reads).toHaveLength(2)) + expect(owner.captureRead()).toEqual(completed) + await act(async () => reads[1].resolve({ models: [], current: { model: 'other-client' } })) + await waitFor(() => expect(owner.captureRead()).toEqual([])) + expect(owner.overlay()).toEqual({}) + expect(capture().drawn).toBe('other-client') + } +) + +const environment = (revision: number): PublicKnownRuntimeEnvironment => ({ + id: target.environmentId, + name: 'restored', + createdAt: 1, + updatedAt: revision, + pairingRevision: revision, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'loopback' +}) + +it.each(['present', 'initially-absent', 'already-hydrated'] as const)( + 'initializes restored hook affinity only at the first authoritative publication (%s)', + async (mode) => { + useAppStore.getState().setRuntimeEnvironments([]) + useAppStore.setState({ + runtimeEnvironmentCatalogHydrated: mode === 'already-hydrated', + runtimeEnvironmentCatalogSettled: false + }) + const list = vi + .fn() + .mockRejectedValueOnce(new Error('first list failed')) + .mockResolvedValue([environment(1)]) + const previousApi = window.api + Object.defineProperty(window, 'api', { + value: { runtimeEnvironments: { list } }, + configurable: true, + writable: true + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + if (mode !== 'already-hydrated') { + await useAppStore.getState().hydrateRuntimeEnvironmentStatuses() + expect(useAppStore.getState().runtimeEnvironmentCatalogHydrated).toBe(false) + expect(useAppStore.getState().runtimeEnvironmentCatalogSettled).toBe(true) + expect((await listRuntimeSessionHostsForStartup()).hostIds).toEqual(['runtime:paired-host']) + } + const restored = structuredAgentSessionTargetForTab(useAppStore.getState(), { + worktreeId: 'restored', + executionHostId: 'runtime:paired-host' + })! + transport.call.mockResolvedValue({ models: [], current: { model: 'gpt-5.5' } }) + const hook = renderHook( + ({ visible }) => + useStructuredAgentSessionOptionState({ + agent: 'codex', + optionCatalog: catalog, + identity: `codex:${sessionId}`, + fence: 7, + sessionId, + target: restored, + newLaunch: false, + seedsModel: false, + providerVisible: visible, + providerStarting: false, + hostOptionStamp: '', + readsBeforeStart: false, + turnId: null, + unloadedTurnRevisions: undefined + }), + { initialProps: { visible: false } } + ) + const owner = hook.result.current.controller + const held = owner.issue('model', 'gpt-5.4', null) + const unqualified = vi.fn() + await expect(owner.request(unqualified)).rejects.toThrow('unavailable') + expect(unqualified).not.toHaveBeenCalled() + act(() => + useAppStore.getState().setRuntimeEnvironments(mode === 'present' ? [environment(1)] : []) + ) + expect(hook.result.current.controller).toBe(owner) + if (mode === 'present') { + hook.rerender({ visible: true }) + await waitFor(() => + expect(transport.call).toHaveBeenCalledWith( + restored, + 'agentSession.options', + expect.anything(), + { expectedEnvironmentPairingRevision: 1 } + ) + ) + expect(held.outcome).toBeUndefined() + act(() => useAppStore.getState().setRuntimeEnvironments([environment(2)])) + expect(held.outcome).toEqual({ kind: 'superseded' }) + await expect(owner.request(async () => 'unsafe')).rejects.toThrow('retired') + } else { + act(() => useAppStore.getState().setRuntimeEnvironments([environment(2)])) + const send = vi.fn() + await expect(owner.request(send)).rejects.toThrow('unavailable') + expect(send).not.toHaveBeenCalled() + } + } finally { + error.mockRestore() + Object.defineProperty(window, 'api', { + value: previousApi, + configurable: true, + writable: true + }) + } + } +) + +it.each([false, true])( + 'retains the answered desktop descriptor and capture across contact, not true end (pending=%s)', + async (pending) => { + resetHostModelCatalogSnapshotsForTests() + useAppStore.getState().setRuntimeEnvironments([environment(1)]) + publish('answered-runtime-1', 1) + const answer: AgentSessionOptionsResult = { + models: ['gpt-5.5', 'gpt-5.4'].map((id) => ({ + id, + label: id, + isDefault: false, + efforts: [] + })), + current: { + model: 'gpt-5.5', + permissionMode: 'own', + reportedPermissionMode: 'full-access', + acceptedInputSources: { model: 'saved' } + } + } + const reads: ReturnType>[] = [] + const write = Promise.withResolvers() + transport.call.mockImplementation((_target, method) => { + if (method === 'agentSession.setOption') { + return write.promise + } + if (method === 'agentSession.modelCatalog') { + return Promise.resolve({ origin: 'probe', models: answer.models, fetchedAt: 1 }) + } + if (method !== 'agentSession.options') { + return new Promise(() => {}) + } + const read = Promise.withResolvers() + reads.push(read) + return read.promise + }) + const hook = renderHook( + ({ stamp, fence }) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId, + target, + fence, + transportEnabled: true, + isVisible: true, + providerVisible: true, + turnId: null, + unloadedTurnRevisions: undefined, + hostOptionStamp: stamp, + mutate: unavailableMutation + }), + { initialProps: { stamp: 'initial', fence: 1 } } + ) + const owner = structuredSessionSelectionController(target, sessionId, 'codex') + await waitFor(() => expect(reads).toHaveLength(1)) + await act(async () => reads[0].resolve(answer)) + const descriptor = (id: string) => + hook.result.current.optionSnapshot.find((row) => row.id === id) + const capture = () => { + const source = hook.result.current.sendSource + return structuredAgentSessionMessageSelection( + source.optionView(source.optionStateRef.current, undefined) + ) + } + expect(descriptor('model')?.kind).toMatchObject({ currentValue: 'gpt-5.5' }) + expect(descriptor('permissionMode')).toMatchObject({ + description: 'Full access', + settable: true + }) + let picked: Promise | undefined + if (pending) { + act(() => { + picked = hook.result.current.setStructuredOption('model', 'gpt-5.4') + }) + } + hook.rerender({ stamp: 'old-read', fence: 1 }) + await waitFor(() => expect(reads).toHaveLength(2)) + act(() => publish('answered-runtime-2', 2)) + await waitFor(() => expect(reads.length).toBeGreaterThanOrEqual(3)) + expect(structuredSessionSelectionController(target, sessionId, 'codex')).toBe(owner) + const model = pending ? 'gpt-5.4' : 'gpt-5.5' + expect(descriptor('model')?.kind).toMatchObject({ currentValue: model }) + expect(capture()).toEqual({ model }) + expect(hook.result.current.sendSource.optionStateRef.current.acceptedInputSources).toEqual({ + model: 'saved' + }) + expect(descriptor('permissionMode')).toMatchObject({ + description: 'Permissions', + settable: false + }) + await act(async () => + reads[1].resolve({ + ...answer, + conversationCommands: ['compact'], + current: { model: 'stale' } + }) + ) + expect(hook.result.current.conversationCommands).toEqual([]) + if (pending) { + await act(async () => write.resolve({ ok: true, value: { options: { model } } })) + await expect(picked).resolves.toBe(true) + expect(owner.captureRead()).toHaveLength(1) + } + await act(async () => + reads[2].resolve({ ...answer, current: { model: 'gpt-5.5', acceptedInputSources: {} } }) + ) + if (pending) { + await waitFor(() => expect(reads).toHaveLength(4)) + expect(owner.captureRead()).toHaveLength(1) + } + if (reads[3]) { + await act(async () => + reads[3].resolve({ ...answer, current: { model: 'gpt-5.5', acceptedInputSources: {} } }) + ) + } + await waitFor(() => + expect(hook.result.current.sendSource.optionStateRef.current.acceptedInputSources).toEqual({}) + ) + expect(owner.captureRead()).toEqual([]) + expect(descriptor('model')?.kind).toMatchObject({ currentValue: 'gpt-5.5' }) + expect(capture()).toEqual({}) + act(() => forgetStructuredSessionSelection(sessionId, target)) + hook.rerender({ stamp: 'new-owner', fence: 2 }) + expect(structuredSessionSelectionController(target, sessionId, 'codex')).not.toBe(owner) + await waitFor(() => + expect(hook.result.current.sendSource.optionStateRef.current.optionsAnswerPresent).not.toBe( + true + ) + ) + expect(capture()).toEqual({}) + await expect(owner.request(async () => 'unsafe')).rejects.toThrow('retired') + } +) + +it('releases the actual first-publication observer when its owner ends before publication', async () => { + useAppStore.getState().setRuntimeEnvironments([]) + useAppStore.setState({ runtimeEnvironmentCatalogHydrated: false }) + const released = vi.fn() + const restore = registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe(listener) { + const stop = useAppStore.subscribe(listener) + return () => { + released() + stop() + } + } + }) + try { + const owner = structuredSessionSelectionController(target, sessionId, 'codex') + const pick = owner.issue('model', 'gpt-5.4', null) + forgetStructuredSessionSelection(sessionId, target) + await expect(pick.result).resolves.toEqual({ kind: 'superseded' }) + expect(released).toHaveBeenCalledTimes(1) + useAppStore.getState().setRuntimeEnvironments([environment(1)]) + expect(released).toHaveBeenCalledTimes(1) + await expect(owner.request(async () => 'unsafe')).rejects.toThrow('retired') + const replacement = structuredSessionSelectionController(target, sessionId, 'codex') + expect(replacement).not.toBe(owner) + await expect(replacement.request(async (options) => options)).resolves.toEqual({ + expectedEnvironmentPairingRevision: 1 + }) + } finally { + forgetStructuredSessionSelection(sessionId, target) + restore() + } +}) diff --git a/src/renderer/src/runtime/runtime-environment-revision.ts b/src/renderer/src/runtime/runtime-environment-revision.ts index 0882ffc90fd..15117c8f598 100644 --- a/src/renderer/src/runtime/runtime-environment-revision.ts +++ b/src/renderer/src/runtime/runtime-environment-revision.ts @@ -85,6 +85,14 @@ export function getRuntimeEnvironmentRevision(environmentId: string): number | u return revisionByEnvironmentId.get(environmentId) } +/** Selected catalog projection also changes when a pending host key is proved at the same revision. */ +export function runtimeEnvironmentContinuityStamp(environmentId: string): string { + return JSON.stringify([ + revisionByEnvironmentId.get(environmentId), + pendingContinuationByEnvironmentId.get(environmentId) + ]) +} + /** * The newest pairing proven to be the same machine as `revision`. A holder of an older pairing may * follow a same-host rotation, but never a re-pair whose host identity is different or unresolved. diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.test.ts b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts index faf3c858009..c8128f749ba 100644 --- a/src/renderer/src/runtime/runtime-host-contact-regained.test.ts +++ b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts @@ -20,6 +20,12 @@ vi.mock('@/store', async () => { import { useAppStore } from '@/store' import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' +import { + structuredSessionSelectionController, + forgetStructuredSessionSelection, + registerStructuredSelectionCatalogStore +} from '@/lib/structured-agent-session-selection-controller' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' function makeStatus(runtimeId: string): RuntimeStatus { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture carries only the fields the slice reads. @@ -35,6 +41,26 @@ function makeStatus(runtimeId: string): RuntimeStatus { } as RuntimeStatus } +const env = (revision: number, key?: string): PublicKnownRuntimeEnvironment => ({ + id: 'env-a', + name: 'managed', + createdAt: 1, + updatedAt: revision, + pairingRevision: revision, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'loopback', + connectionDependency: 'ssh-tunnel', + hostKeyFingerprint: key, + orcadDeployment: { + sshTargetId: 'box', + sshTargetGeneration: 1, + localPort: 46768, + remotePort: 6768 + } +}) + let lastVerified: RuntimeStatus | null = null // Same-runtime return retains verified status and advances contact epoch, not connection generation. @@ -55,7 +81,12 @@ function publish(status: RuntimeStatus | null, checkedAt: number): void { }) } +let restoreCatalog: () => void beforeEach(() => { + restoreCatalog = registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe: useAppStore.subscribe + }) clearRuntimeEnvironmentConnectionGenerationsForTests() vi.stubGlobal('window', { api: {}, dispatchEvent: vi.fn() }) useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) @@ -63,6 +94,9 @@ beforeEach(() => { }) afterEach(() => { + forgetStructuredSessionSelection('continuity') + forgetStructuredSessionSelection('keyless') + restoreCatalog() vi.unstubAllGlobals() }) @@ -107,4 +141,50 @@ describe('subscribeRuntimeHostContactRegained', () => { expect(listener).not.toHaveBeenCalled() stop() }) + + it('wakes a parked owner when a pending managed pairing proves its key without another revision', async () => { + useAppStore.getState().setRuntimeEnvironments([env(1, 'key-A')]) + const target = { kind: 'environment', environmentId: 'env-a' } as const + const owner = structuredSessionSelectionController(target, 'continuity', 'codex') + const held = owner.issue('model', 'gpt-5.4', null) + const listener = vi.fn() + const stop = subscribeRuntimeHostContactRegained('env-a', listener) + useAppStore.getState().setRuntimeEnvironments([env(2)]) + expect(structuredSessionSelectionController(target, 'continuity', 'codex')).toBe(owner) + expect(held.outcome).toBeUndefined() + const pendingWakes = listener.mock.calls.length + useAppStore.getState().setRuntimeEnvironments([env(2, 'key-A')]) + expect(listener).toHaveBeenCalledTimes(pendingWakes + 1) + useAppStore.getState().setRuntimeEnvironments([env(2, 'key-A')]) + expect(listener).toHaveBeenCalledTimes(pendingWakes + 1) + useAppStore.getState().setRuntimeEnvironments([env(3)]) + expect(structuredSessionSelectionController(target, 'continuity', 'codex')).toBe(owner) + expect(held.outcome).toBeUndefined() + useAppStore.getState().setRuntimeEnvironments([env(3, 'key-A')]) + const send = vi.fn().mockResolvedValue('current') + await owner.request(send) + expect(send).toHaveBeenCalledWith({ expectedEnvironmentPairingRevision: 3 }) + useAppStore.getState().setRuntimeEnvironments([env(4, 'different')]) + expect(held.outcome).toEqual({ kind: 'superseded' }) + expect(structuredSessionSelectionController(target, 'continuity', 'codex')).not.toBe(owner) + stop() + forgetStructuredSessionSelection('continuity') + }) +}) + +it('ends supported keyless SSH affinity when the classifier supplies neither proof nor pending', async () => { + useAppStore.getState().setRuntimeEnvironments([env(1)]) + const target = { kind: 'environment', environmentId: 'env-a' } as const + const owner = structuredSessionSelectionController(target, 'keyless', 'codex') + const held = owner.issue('model', 'gpt-5.4', null) + useAppStore.getState().setRuntimeEnvironments([env(2)]) + await expect(held.result).resolves.toEqual({ kind: 'superseded' }) + const send = vi.fn().mockResolvedValue('current') + await expect(owner.request(send)).rejects.toThrow('retired') + expect(send).not.toHaveBeenCalled() + const fresh = structuredSessionSelectionController(target, 'keyless', 'codex') + expect(fresh).not.toBe(owner) + await expect(fresh.request(send)).resolves.toBe('current') + expect(send).toHaveBeenCalledWith({ expectedEnvironmentPairingRevision: 2 }) + forgetStructuredSessionSelection('keyless') }) diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.ts b/src/renderer/src/runtime/runtime-host-contact-regained.ts index b829f51d99a..4ba155a4bad 100644 --- a/src/renderer/src/runtime/runtime-host-contact-regained.ts +++ b/src/renderer/src/runtime/runtime-host-contact-regained.ts @@ -1,15 +1,22 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { runtimeEnvironmentContinuityStamp } from './runtime-environment-revision' -function contactMarks(state: AppState, environmentId: string): string | null { +function contactMarks(state: AppState, environmentId: string): string { const entry = state.runtimeStatusByEnvironmentId.get(environmentId) - return entry ? `${entry.hostContactEpoch ?? 0}:${entry.connectionGeneration ?? 0}` : null + return entry + ? JSON.stringify([ + entry.hostContactEpoch ?? 0, + entry.connectionGeneration ?? 0, + runtimeEnvironmentContinuityStamp(environmentId) + ]) + : runtimeEnvironmentContinuityStamp(environmentId) } /** * Calls `listener` each time this client regains contact with an environment's runtime: the * same-runtime return (`hostContactEpoch`) or a new runtime session (`connectionGeneration`), - * both published by runtime-status.ts. + * or resolved pairing continuity published by runtime-status.ts. */ export function subscribeRuntimeHostContactRegained( environmentId: string, @@ -17,12 +24,14 @@ export function subscribeRuntimeHostContactRegained( ): () => void { let lastMarks = contactMarks(useAppStore.getState(), environmentId) return useAppStore.subscribe((state, previousState) => { - if (state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId) { + if ( + state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && + state.runtimeEnvironments === previousState.runtimeEnvironments + ) { return } const marks = contactMarks(state, environmentId) - // Why keep the last marks across a missing entry: a cleared status that returns is a reconnect. - if (marks === null || marks === lastMarks) { + if (marks === lastMarks) { return } lastMarks = marks diff --git a/src/renderer/src/runtime/runtime-rpc-selection-order.test.ts b/src/renderer/src/runtime/runtime-rpc-selection-order.test.ts new file mode 100644 index 00000000000..8e18f857eaa --- /dev/null +++ b/src/renderer/src/runtime/runtime-rpc-selection-order.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { createStructuredSessionSelectionController } from '../../../shared/structured-agent-session-selection-operations' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' +import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const runtimeCall = vi.fn() +const runtimeEnvironmentCall = vi.fn() +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeCall.mockReset() + runtimeEnvironmentCall.mockReset() + vi.stubGlobal('window', { + api: { runtime: { call: runtimeCall }, runtimeEnvironments: { call: runtimeEnvironmentCall } } + }) +}) + +it.each(['local', 'environment'] as const)( + 'issues Plan and legacy tier before Send while the model response waits (%s)', + async (kind) => { + const delayedModel = Promise.withResolvers() + const host = { permissionMode: 'full-access', serviceTier: 'default', model: 'opus' } + const enqueued: string[] = [] + const receive = async ({ + method, + params + }: { + method: string + params?: { key?: string; value?: string } + }) => { + if (method === 'status.get') { + return { + id: method, + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'remote-runtime' } + } + } + enqueued.push(params?.key ?? method) + if (method === 'agentSession.setOption') { + if (params?.key === 'model') { + host.model = params.value! + return delayedModel.promise + } + if (params?.key === 'permissionMode') { + host.permissionMode = params.value! + } + if (params?.key === 'serviceTier') { + host.serviceTier = params.value! + } + } + return { id: method, ok: true, result: { ...host }, _meta: { runtimeId: 'remote-runtime' } } + } + runtimeCall.mockImplementation(receive) + runtimeEnvironmentCall.mockImplementation(receive) + const target = + kind === 'local' + ? { kind: 'local' as const } + : { kind: 'environment' as const, environmentId: 'w2-peer' } + const controller = createStructuredSessionSelectionController(() => {}) + const dispatch = () => + controller.flush(async (operation) => { + await callRuntimeRpc(target, 'agentSession.setOption', { + key: operation.key, + value: operation.value + }) + return { kind: 'accepted' as const, options: { [operation.key]: operation.value } } + }) + controller.issue('model', 'sonnet', null) + dispatch() + const draining = controller.drain() + await vi.waitFor(() => expect(enqueued).toEqual(['model'])) + controller.issue('permissionMode', 'plan', null) + controller.issue('serviceTier', 'priority', 'sonnet') + void dispatch() + const delivered = await callRuntimeRpc(target, 'agentSession.send', {}) + expect(enqueued).toEqual(['model', 'serviceTier', 'permissionMode', 'agentSession.send']) + expect(delivered).toMatchObject({ + model: 'sonnet', + permissionMode: 'plan', + serviceTier: 'priority' + }) + expect(controller.overlay()).toMatchObject({ permissionMode: 'plan', serviceTier: 'priority' }) + delayedModel.resolve({ + id: 'model', + ok: true, + result: {}, + _meta: { runtimeId: 'remote-runtime' } + }) + await draining + controller.retire(controller.captureRead()) + expect(controller.overlay()).toEqual({}) + expect(host).toEqual(delivered) + } +) diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index d4e8717cd34..e3ecb9b31e9 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -37,7 +37,7 @@ import { subscribeRuntimeRpc } from './runtime-rpc-subscribe' /** Read a capability through the runtime's existing status cache. A failed/unknown * probe is treated as legacy so a newer call is never made before the host has * proved it understands it. */ -async function structuredAgentSessionHostSupports( +export async function structuredAgentSessionHostSupports( target: RuntimeClientTarget, capability: RuntimeCapability ): Promise { @@ -166,7 +166,11 @@ export async function callStructuredAgentSession( method: string, params?: unknown, /** For a caller that checked the remote host's compatibility itself, just before. */ - options: { skipCompatibilityCheck?: true; beforeSend?: () => void } = {} + options: { + skipCompatibilityCheck?: true + beforeSend?: () => void + expectedEnvironmentPairingRevision?: number + } = {} ): Promise { if ( method === 'agentSession.rewind' && @@ -179,7 +183,7 @@ export async function callStructuredAgentSession( throw new Error('Rewinding requires a newer Orca server. Update the server and try again.') } const timeoutMs = STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method) - return timeoutMs === undefined && !options.skipCompatibilityCheck && !options.beforeSend + return timeoutMs === undefined && Object.keys(options).length === 0 ? callRuntimeRpc(target, method, params) : callRuntimeRpc(target, method, params, { ...(timeoutMs === undefined ? {} : { timeoutMs }), diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx index 482fc6d2be1..ea7e36155ca 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx @@ -7,10 +7,18 @@ vi.mock('../components/native-chat/StructuredAgentSessionStatusBridge', () => ({ useStructuredAgentSessionStatusSummary: () => ({ summary: null, observation: 0 }) })) // Existing non-Own action gates have their own unchanged request/recovery contracts. -vi.mock('./structured-agent-session-host-capability', async (original) => ({ - ...(await original()), - useStructuredAgentSessionHostCapability: () => false -})) +vi.mock('./structured-agent-session-host-capability', async (original) => { + const actual = await original() + return { + ...actual, + useStructuredAgentSessionHostCapability: ( + ...[target, capability]: Parameters + ) => + capability === 'agent-session.permission-modes.v1' + ? actual.useStructuredAgentSessionHostCapability(target, capability) + : false + } +}) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('./structured-agent-session-client', () => ({ callStructuredAgentSession: async () => ({ @@ -36,6 +44,7 @@ const owners: RuntimeHostStatusOwner[] = [] afterEach(() => { owners.splice(0).forEach((owner) => owner.dispose()) cleanup() + useAppStore.getState().setRuntimeEnvironments([]) clearRuntimeCompatibilityCacheForTests() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -44,22 +53,20 @@ afterEach(() => { it.each([10, 100])( 'enables %s mounted consumers from the existing connection owner', async (count) => { - useAppStore.setState({ - runtimeEnvironments: [ - { - id: 'host', - name: 'host', - createdAt: 1, - updatedAt: 1, - pairingRevision: 1, - lastUsedAt: null, - runtimeId: null, - endpoints: [], - preferredEndpointId: 'host' - } - ], - runtimeStatusByEnvironmentId: new Map() - }) + useAppStore.getState().setRuntimeEnvironments([ + { + id: 'host', + name: 'host', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'host' + } + ]) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) let response = Promise.withResolvers>() const request = vi.fn(() => response.promise) const owner = new RuntimeHostStatusOwner({ diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts index 48537f2d5cb..411ddd19569 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts @@ -5,6 +5,7 @@ import type { StructuredLaunchSlotOwner } from '@/lib/agent-session/launch/struc import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' const mocks = vi.hoisted(() => ({ + remember: vi.fn(), closeSession: vi.fn<(target: RuntimeClientTarget, sessionId: string) => Promise<'closed' | 'unsupported'>>(), callRuntime: @@ -29,6 +30,9 @@ vi.mock('@/lib/agent-session/launch/structured-agent-session-launch-registry', ( hasStructuredAgentSessionLaunchCancellationTombstone: mocks.hasTombstone, markStructuredAgentSessionLaunchCancelled: mocks.markCancelled })) +vi.mock('@/components/native-chat/native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: mocks.remember +})) vi.mock('@/lib/structured-agent-session-launch-prompt', () => ({ discardStructuredAgentSessionChatSends: mocks.discardOutbox })) @@ -74,6 +78,14 @@ import { import { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + structuredSessionSelectionController, + forgetStructuredSessionSelection +} from '@/lib/structured-agent-session-selection-controller' +import { retirePurgedTabLaunches } from '@/store/slices/worktrees/teardown/purged-structured-launch-retirement' +import { useAppStore } from '@/store' +import { agentSessionTabFixture } from '@/components/native-chat/native-chat-workspace-test-fixtures' + const layoutOwner: StructuredLaunchSlotOwner = { unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -376,3 +388,100 @@ describe('structured agent session tab retirement', () => { warn.mockRestore() }) }) + +it('releases ordinary close/purge owners while retaining the same-session other-host scope', async () => { + const remote = { kind: 'environment', environmentId: 'selection-generations' } as const + const local = { kind: 'local' } as const + const sessionId = 'selection-lifetime' + const retained = new Set() + const set = Map.prototype.set + const del = Map.prototype.delete + const added = vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key, + value + ) { + if (value?.sessionId === sessionId && value?.controller) { + retained.add(key) + } + return set.call(this, key, value) + }) + const deleted = vi.spyOn(Map.prototype, 'delete').mockImplementation(function ( + this: Map, + key + ) { + retained.delete(key) + return del.call(this, key) + }) + try { + const previous = structuredSessionSelectionController(remote, sessionId, 'codex') + previous.issue('model', 'model-1', null) + expect(retained.size).toBe(1) + const survivor = structuredSessionSelectionController(local, sessionId, 'codex') + survivor.issue('model', 'surviving-model', null) + expect(retained.size).toBe(2) + const reply = Promise.withResolvers<{ kind: 'accepted'; options: Record }>() + previous!.flush(() => previous!.request(() => reply.promise)) + const drain = previous!.drain() + await retireStructuredAgentSessionTab({ target: remote, worktreeId: 'wt-1', sessionId }) + await drain + expect(retained.size).toBe(1) + expect(survivor.overlay()).toEqual({ model: 'surviving-model' }) + // The host can still finish the accepted write; no obsolete client owner remembers it. + reply.resolve({ kind: 'accepted', options: { model: 'model-1' } }) + await reply.promise + await Promise.resolve() + expect(mocks.remember).not.toHaveBeenCalled() + expect(previous!.overlay()).toEqual({}) + const noSend = vi.fn() + await expect(previous!.issue('model', 'dead', null).result).resolves.toEqual({ + kind: 'superseded' + }) + previous!.flush(noSend) + expect(previous!.freeze()).toEqual([]) + await expect(previous!.request(noSend)).rejects.toThrow('retired') + expect(noSend).not.toHaveBeenCalled() + const purged = structuredSessionSelectionController(remote, sessionId, 'codex') + expect(purged).not.toBe(previous) + expect(purged.captureRead()).toEqual([]) + purged.issue('effort', 'high', 'sonnet') + retirePurgedTabLaunches( + useAppStore.getState(), + 'wt-1', + [], + [ + agentSessionTabFixture(sessionId, 'wt-1', { + entityId: sessionId, + executionHostId: 'runtime:selection-generations' + }) + ], + new Set([sessionId]) + ) + expect(purged.overlay()).toEqual({}) + expect(retained.size).toBe(1) + expect(structuredSessionSelectionController(local, sessionId, 'codex')).toBe(survivor) + expect(survivor.overlay()).toEqual({ model: 'surviving-model' }) + survivor.settle(survivor.pending()[0]!, { + kind: 'accepted', + options: { model: 'surviving-model' } + }) + expect(mocks.remember).toHaveBeenLastCalledWith(local, { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'surviving-model', optionId: 'model', value: 'surviving-model' }] + }) + console.info( + 'selection-registry-lifetime', + JSON.stringify({ + ownerScopes: 2, + retainedPerChat: 1, + retainedAfterScopedClose: 1, + retainedAfterScopedPurge: 1 + }) + ) + } finally { + forgetStructuredSessionSelection(sessionId) + added.mockRestore() + deleted.mockRestore() + } +}) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 1a714a4422c..528962d9eb2 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -19,6 +19,7 @@ import type { WebSessionIntentOwner } from './web-session-intent-owner' import { captureWebSessionIntentOwner } from './web-runtime-session-environment' import { callRuntimeRpc } from './runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' const inFlightRetirements = new Map>() @@ -38,6 +39,7 @@ export function retireStructuredAgentSessionTab(args: { onError?: (error: unknown) => void }): Promise { retireStructuredAgentSessionReadOwner(args.sessionId, args.target) + forgetStructuredSessionSelection(args.sessionId, args.target) const key = retirementKey(args.target, args.worktreeId, args.sessionId) const existing = inFlightRetirements.get(key) if (existing) { diff --git a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts index 085bf298f06..23d86d16c14 100644 --- a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts +++ b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts @@ -125,7 +125,7 @@ function pendingLaunch(executionHostId: ExecutionHostId, target: RuntimeClientTa promise: new Promise(() => undefined), visibilityUnknown: false, cancelled: false, - selection: { held: {} } + selection: {} } satisfies StructuredLaunchState) } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 932960ea933..b7c83ea7d4d 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -1,3 +1,4 @@ +import { registerStructuredSelectionCatalogStore } from '@/lib/structured-agent-session-selection-controller' import { canOfferHttpLinkGroupAction } from '@/lib/http-link-in-group-availability' import { create, type StateCreator } from 'zustand' import type { AppState } from './types' @@ -150,6 +151,10 @@ export const useAppStore = create()( ) ) +registerStructuredSelectionCatalogStore({ + read: useAppStore.getState, + subscribe: useAppStore.subscribe +}) registerHttpLinkStoreAccessor(() => useAppStore.getState(), useAppStore.subscribe) registerHttpLinkGroupActionAvailability((request) => canOfferHttpLinkGroupAction(useAppStore.getState(), request) diff --git a/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts b/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts index 12b309f0c39..de3a0b6f0ee 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts @@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { TerminalTab } from '../../../../../../shared/terminal-tab-types' import type { Tab } from '../../../../../../shared/tab-types' import type { AppState } from '../../../types' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' import { getStructuredAgentSessionLaunchOwner, @@ -44,6 +45,7 @@ export function retirePurgedTabLaunches( ) const target = structuredAgentSessionTargetForTab(s, tab) if (target) { + forgetStructuredSessionSelection(tab.entityId, target) retireStructuredAgentSessionReadOwner(tab.entityId, target) } } diff --git a/src/shared/agent-session-create-capabilities.ts b/src/shared/agent-session-create-capabilities.ts index f2877c02f30..8133e562cd6 100644 --- a/src/shared/agent-session-create-capabilities.ts +++ b/src/shared/agent-session-create-capabilities.ts @@ -30,7 +30,11 @@ export const AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY = export const AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY = 'agent-session.send-usage-limit-continuation.v1' as const +export const AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY = + 'agent-session.send-selection.v1' as const + export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [ + AGENT_SESSION_SEND_SELECTION_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-session-execution-options.ts b/src/shared/agent-session-execution-options.ts index 9639c79b556..c0d34df650f 100644 --- a/src/shared/agent-session-execution-options.ts +++ b/src/shared/agent-session-execution-options.ts @@ -27,3 +27,8 @@ export function agentSessionExecutionOptionsRecord( Object.entries(options).filter((entry): entry is [string, string] => entry[1] !== undefined) ) } + +/** Exact picker domain; personality remains host-captured. */ +export const AgentSessionSelectionSchema = AgentSessionExecutionOptionsSchema.omit({ + personality: true +}).strict() diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts index 5c4e0ebfd36..9930fbc7f73 100644 --- a/src/shared/agent-session-options-result.ts +++ b/src/shared/agent-session-options-result.ts @@ -8,7 +8,12 @@ import type { AgentSessionModelOption } from './agent-session-wire' -/** Additive read-only choices/effective next-turn values; older hosts may reject this without changing structured v1 writes. */ +export type AgentSessionAcceptedInputSources = Partial< + Record<'model' | 'effort' | 'fastMode' | 'serviceTier', 'saved' | 'seeded' | 'policy'> +> + +/** Provider-reported choices and effective next-turn values. Additive read-only + * surface so older hosts can reject it without changing structured v1 writes. */ export type AgentSessionOptionsResult = { rewind?: AgentSessionRewindSupport conversationCommands?: readonly AgentSessionConversationCommand[] @@ -27,6 +32,8 @@ export type AgentSessionOptionsResult = { /** Session/account/transport support. Absent means unknown, never unsupported. */ fastModeSupport?: AgentSessionFastModeSupport current: { + /** Accepted input origins; an empty map excludes all display-only observations. */ + acceptedInputSources?: AgentSessionAcceptedInputSources model?: string effort?: string /** Canonical preference for the next turn. Explicit false is meaningful. */ diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 45c5d449bae..c807ef8a7df 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -224,6 +224,11 @@ export type AgentSessionStatusSummary = { /** The chat's saved permission mode; a client re-reads options when it or `model` moves. */ permissionMode?: string runtimePermission?: { fence: number; mode: string | null } | null + /** The chat's saved effort, Fast and speed picks, encoded; another device's pick moves them so a + * client re-reads options. Absent from older hosts. */ + effort?: string + fastMode?: string + serviceTier?: string /** The tool the running turn is inside, else the last one it used. Absent unless `status` * is 'working'. */ toolName?: string diff --git a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts index bdcbdedaaa3..8db8d28bbfb 100644 --- a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts +++ b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts @@ -5,6 +5,7 @@ import { CreateIntentParams, CreateSupportParams, ModelCatalogParams, + SendParams, RespondToQuestionParams } from './structured-agent-session-params' import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer' @@ -64,6 +65,29 @@ describe('structured agent params', () => { }).success ).toBe(false) }) + it('accepts an exact empty selection but rejects hidden keys and dual legacy options', () => { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'send' }] } + const send = { envelope: { ...ENVELOPE, expectedRuntimeFence: 1 }, body } + const create = { envelope: ENVELOPE, worktree: 'wt', agent: 'codex' } + for (const selection of [ + {}, + { model: 'literal-alias', fastMode: 'false', serviceTier: 'priority' } + ]) { + expect(SendParams.safeParse({ ...send, selection }).success).toBe(true) + expect( + CreateIntentParams.safeParse({ + ...create, + firstMessage: { clientMessageId: 'first', body, selection } + }).success + ).toBe(true) + } + expect(SendParams.safeParse({ ...send, selection: {}, options: {} }).success).toBe(false) + expect(SendParams.safeParse({ ...send, selection: { personality: 'friendly' } }).success).toBe( + false + ) + expect(SendParams.safeParse(send).success).toBe(true) + }) + it.each(['claude', 'codex', 'grok', 'qwen-code'])('accept the agent id %s', (agent) => { expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(true) expect(ModelCatalogParams.safeParse({ agent }).success).toBe(true) diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 8d3d95d69af..d9072db1fb0 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,3 +1,4 @@ +import { AgentSessionSelectionSchema } from '../agent-session-execution-options' import { SendBody, MAX_OPTION_LABEL, @@ -126,11 +127,16 @@ export const SendParams = z delivery: z.literal('queue-if-active').optional(), /** Preserve composer options until dispatch; requires agent-session.send-options.v1. Operation fingerprint only. */ options: AgentSessionOptions.optional(), + selection: AgentSessionSelectionSchema.optional(), // Strict payload: only hosts advertising the bound usage-limit send capability accept this. usageLimitContinuationOfTurnItemId: Identifier('Invalid source turn item id').optional(), body: SendBody }) .strict() + .refine( + (value) => value.options === undefined || value.selection === undefined, + 'Choose options or selection' + ) export const CreateIntentParams = z .object({ @@ -143,7 +149,8 @@ export const CreateIntentParams = z firstMessage: z .object({ clientMessageId: Identifier('Invalid client message id'), - body: SendParams.shape.body + body: SendBody, + selection: AgentSessionSelectionSchema.optional() }) .strict() .optional(), diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 53064bbeed9..68a68619f29 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -33,6 +33,7 @@ export type StructuredAgentSessionCreateParams = { export type StructuredAgentSessionFirstMessage = { clientMessageId: string body: z.infer + selection?: Readonly> } /** Provider-prefixed IDs use underscore-only suffix tokens for embedding in tab IDs/log keys. */ diff --git a/src/shared/structured-agent-session-option-snapshot.ts b/src/shared/structured-agent-session-option-snapshot.ts index d7b6b109f73..effb0c43e8a 100644 --- a/src/shared/structured-agent-session-option-snapshot.ts +++ b/src/shared/structured-agent-session-option-snapshot.ts @@ -234,7 +234,9 @@ export function canSetStructuredAgentSessionOption( return ( descriptor?.settable === true && descriptor.kind.type === 'select' && - descriptor.kind.choices.some((choice) => choice.value === value) + (value === 'own' + ? state.permissionOwnSupported === true + : descriptor.kind.choices.some((choice) => choice.value === value)) ) } return ( diff --git a/src/shared/structured-agent-session-option-state.ts b/src/shared/structured-agent-session-option-state.ts index 113f4f589d8..54db07b204d 100644 --- a/src/shared/structured-agent-session-option-state.ts +++ b/src/shared/structured-agent-session-option-state.ts @@ -70,7 +70,20 @@ export function replaceStructuredAgentSessionAnsweredValues( /** The host status's option values as one stamp: another device's pick moves it, so options re-read. */ export function structuredAgentSessionOptionStamp( - summary: Pick | undefined + summary: + | Pick< + AgentSessionStatusSummary, + 'model' | 'permissionMode' | 'effort' | 'fastMode' | 'serviceTier' + > + | undefined ): string { - return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + return [ + summary?.model, + summary?.permissionMode, + summary?.effort, + summary?.fastMode, + summary?.serviceTier + ] + .map((value) => value ?? '') + .join('|') } diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index fcf1f2db0c7..cc234da4fe1 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -10,6 +10,7 @@ import { type StructuredAgentSessionOptionState } from './structured-agent-session-options' import { + structuredAgentSessionMessageSelection, structuredAgentSessionOptionView, structuredAgentSessionSendSelection, structuredAgentSessionShownSnapshot @@ -208,3 +209,47 @@ describe("a chat's model and effort, apart from the defaults filling them in", ( }) }) }) + +it('bounds exact capture by catalog width at N and 10N', () => { + const work: number[] = [] + for (const count of [10, 100]) { + let visits = 0 + const models = Array.from({ length: count }, (_, index) => ({ + id: `model-${index}`, + label: `Model ${index}`, + isDefault: false, + efforts: levels('high') + })) + const state = applyStructuredAgentSessionOptions( + fresh(), + CLAUDE_SESSION_OPTION_CATALOG, + { + models, + listedModelIds: models.map((row) => row.id), + current: { + model: `model-${count - 1}`, + effort: 'high', + acceptedInputSources: { model: 'saved', effort: 'saved' } + } + }, + { newLaunch: false, coherentCatalog: false } + ) + for (const row of state.catalog?.models ?? []) { + const id = row.id + Object.defineProperty(row, 'id', { + get() { + visits++ + return id + } + }) + } + expect(structuredAgentSessionMessageSelection(state)).toEqual({ + model: `model-${count - 1}`, + effort: 'high' + }) + expect(visits).toBeLessThanOrEqual(10 * count) + work.push(visits) + } + expect(work[1]).toBeLessThanOrEqual(11 * work[0]) + console.info('selection-capture-catalog-visits', JSON.stringify(work)) +}) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 8bce18f0733..ac8fd4885c4 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -13,14 +13,18 @@ import { } from './structured-agent-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' -/** Derive held picks, host values and launch seeds without mutating the record; retain unlisted models. - * Permission mode uses held, host, explicit launch mode, then seed. */ +/** Query base or immutable launch seed, with finite controller operations above it. + * Display provenance stays separate from accepted input while catalog labels load. */ export function structuredAgentSessionOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, held: Readonly>, explicit?: Readonly> ): StructuredAgentSessionOptionState { + if (state.optionsAnswerPresent) { + seed = undefined + explicit = undefined + } const requested = structuredLaunchRequestedOptions({ explicit, seed, held }) // Only a requested model replaces the shown selection; a permission mode alone overlays it. if (requested?.model !== undefined) { @@ -60,15 +64,27 @@ function modelOptionView( unconfirmedSeed && (state.catalogSource === 'host' || state.catalogSource === 'live') && (held.model === undefined || held.model === seed.model) - if (!state.catalog || (!seeded && !hasHeld)) { + if (!state.catalog || (!unconfirmedSeed && !hasHeld)) { return state } let view: StructuredAgentSessionOptionState = { ...state, record: cloneStructuredAgentSessionOptionRecord(state.record) } - if (seeded) { - view = commitStructuredAgentSessionOptionValues(view, seed) + if (unconfirmedSeed) { + view = commitStructuredAgentSessionOptionValues(view, seed, 'seeded') + if (!seeded) { + // The immutable input remains capturable before a host list can label it. + for (const id of Object.keys(seed)) { + const tracked = view.record.values[id] + if (tracked) { + tracked.source = 'default' + } + } + if (state.catalogSource === 'host' && !hasHeld) { + view = { ...view, catalogSource: 'seed' } + } + } } return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } } @@ -94,3 +110,32 @@ export function structuredAgentSessionSendSelection( ? { ...(model ? { model } : {}), ...(effort ? { effort } : {}) } : undefined } + +/** Exact capture uses accepted origins; absent metadata keeps the older host contract. */ +export function structuredAgentSessionMessageSelection( + view: StructuredAgentSessionOptionState +): Record { + const selection: Record = {} + const snapshot = structuredAgentSessionOptionSnapshot(view) + for (const id of ['model', 'effort', 'fastMode', 'serviceTier'] as const) { + const descriptor = snapshot.find((entry) => entry.id === id) + if (view.acceptedInputSources !== undefined) { + if (!view.acceptedInputSources[id]) { + continue + } + } else if ( + (id === 'model' || id === 'effort') && + view.defaultsUnsent && + descriptor?.valueSource === 'default' + ) { + continue + } + const value = + descriptor?.kind.currentValue ?? + (view.acceptedInputSources !== undefined ? view.record.values[id]?.value : undefined) + if (value !== undefined) { + selection[id] = String(value) + } + } + return selection +} diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index f38b3fd5694..fa374478811 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp' -import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' +import { + CODEX_SESSION_OPTION_CATALOG, + CLAUDE_SESSION_OPTION_CATALOG +} from './agent-session-option-catalog-claude-codex' import { buildNativeChatSessionOptionSnapshot } from './native-chat/input/native-chat-session-option-snapshot' import { createNativeChatSessionOptionRecord } from './native-chat/input/native-chat-session-option-state' import { @@ -13,7 +16,10 @@ import { structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from './structured-agent-session-options' -import { structuredAgentSessionOptionView } from './structured-agent-session-option-view' +import { + structuredAgentSessionOptionView, + structuredAgentSessionMessageSelection +} from './structured-agent-session-option-view' function viewModel( state: StructuredAgentSessionOptionState, @@ -333,3 +339,165 @@ it('separates typed literal readiness from an empty or refreshing catalog', () = ) ).toBe(true) }) + +it.each([false, true])( + 'keeps queried origins when installed metadata arrives, preloaded=%s', + (preloaded) => { + const launch = { newLaunch: true, coherentCatalog: true } + const models = [ + { + id: 'model-a', + label: 'Model A', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ] + const catalog = { + origin: 'probe' as const, + fetchedAt: 1, + listingNamesConfiguredModel: true, + models + } + const seed = createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG) + const state = applyStructuredAgentSessionOptions( + preloaded + ? applyStructuredAgentSessionModelCatalog( + seed, + CODEX_SESSION_OPTION_CATALOG, + catalog, + launch + ) + : seed, + CODEX_SESSION_OPTION_CATALOG, + { + models, + current: { + model: 'model-a', + effort: 'high', + defaulted: ['model', 'effort'], + acceptedInputSources: {} + } + }, + launch + ) + expect(structuredAgentSessionMessageSelection(state)).toEqual({}) + let refreshed = applyStructuredAgentSessionModelCatalog( + state, + CODEX_SESSION_OPTION_CATALOG, + catalog, + launch + ) + refreshed = applyStructuredAgentSessionModelCatalog( + refreshed, + CODEX_SESSION_OPTION_CATALOG, + catalog, + launch + ) + expect(structuredAgentSessionMessageSelection(refreshed)).toEqual({}) + const stale = { model: 'old-unlisted', effort: 'low' } + expect( + structuredAgentSessionMessageSelection( + structuredAgentSessionOptionView(refreshed, stale, {}, stale) + ) + ).toEqual({}) + expect( + structuredAgentSessionMessageSelection( + structuredAgentSessionOptionView(refreshed, stale, { effort: 'low' }, stale) + ) + ).toEqual({ effort: 'low' }) + const legacy = applyStructuredAgentSessionOptions( + refreshed, + CODEX_SESSION_OPTION_CATALOG, + { + models, + current: { model: 'model-a', effort: 'high' } + }, + launch + ) + expect( + applyStructuredAgentSessionModelCatalog(legacy, CODEX_SESSION_OPTION_CATALOG, catalog, launch) + .acceptedInputSources + ).toBeUndefined() + } +) + +it('rederives unanswered catalog policy while preserving stronger input and queried origins', () => { + const launch = { newLaunch: true, coherentCatalog: true } + const catalog = { + origin: 'probe' as const, + fetchedAt: 1, + listingNamesConfiguredModel: true, + fastModeSupport: { supported: true as const }, + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + supportsFastMode: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ] + } + const seed = CLAUDE_SESSION_OPTION_CATALOG + const initial = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', seed), + seed, + catalog, + launch + ) + expect(structuredAgentSessionMessageSelection(initial)).toEqual({ + model: 'opus', + effort: 'high', + fastMode: 'false' + }) + const noDefault = { ...catalog, listingNamesConfiguredModel: false } + expect( + applyStructuredAgentSessionModelCatalog(initial, seed, noDefault, launch).acceptedInputSources + ).toEqual({ fastMode: 'policy' }) + for (const fastMode of ['true', 'false']) { + let chosen = commitStructuredAgentSessionOption(initial, 'model', 'unlisted', 'seeded') + chosen = commitStructuredAgentSessionOption(chosen, 'effort', 'low') + chosen = commitStructuredAgentSessionOption(chosen, 'fastMode', fastMode, 'seeded') + const refreshed = applyStructuredAgentSessionModelCatalog(chosen, seed, noDefault, launch) + expect(refreshed.acceptedInputSources).toEqual({ + model: 'seeded', + effort: 'picked', + fastMode: 'seeded' + }) + expect(structuredAgentSessionMessageSelection(refreshed)).toEqual({ + model: 'unlisted', + effort: 'low', + fastMode + }) + } + const answered = applyStructuredAgentSessionOptions( + initial, + seed, + { + models: catalog.models, + current: { + model: 'opus', + effort: 'low', + fastMode: false, + acceptedInputSources: { model: 'saved', effort: 'saved', fastMode: 'policy' }, + defaulted: [] + } + }, + launch + ) + const refreshed = applyStructuredAgentSessionModelCatalog(answered, seed, noDefault, launch) + expect(refreshed.acceptedInputSources).toEqual(answered.acceptedInputSources) + expect(structuredAgentSessionMessageSelection(refreshed)).toEqual({ + model: 'opus', + effort: 'low', + fastMode: 'false' + }) +}) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 16872c781db..e060e8294fb 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -13,6 +13,7 @@ import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mo import { usesInstalledAgentModelCatalog } from './agent-model-catalog-ownership' import { structuredAgentSessionShownModel } from './structured-agent-session-option-snapshot' import type { SessionOptionValue } from './native-chat/input/native-chat-session-options' +import type { AgentSessionAcceptedInputSources } from './agent-session-options-result' import type { AgentSessionModelCatalogResult, AgentSessionOptionsResult @@ -53,6 +54,10 @@ export type StructuredAgentSessionOptionState = { /** Live-answer IDs verified by the agent’s listing; extra rows name the chat’s own model, not listed capabilities. Old hosts omit this. */ verifiedModelIds?: readonly string[] record: StructuredAgentSessionOptionRecord + optionsAnswerPresent?: true + acceptedInputSources?: Partial< + Record + > /** The last answer came from a host that marks no defaults, whose own tier rule it shows. */ olderHost?: true /** Resumed chats show host defaults without sending them; new chats pin their shown default on first send. */ @@ -75,6 +80,7 @@ export function createStructuredAgentSessionOptionState( catalog: seedCatalog ?? null, catalogSource: seedCatalog ? 'seed' : null, record: createStructuredAgentSessionOptionRecord(agent), + acceptedInputSources: {}, permissionMode: null, pendingId: null } @@ -151,7 +157,18 @@ export function applyStructuredAgentSessionModelCatalog( ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, ...(installed ? { verifiedModelIds: finishedListingIds(catalog) } : {}), - catalogSource: state.catalogSource === 'live' ? 'live' : 'host' + catalogSource: state.catalogSource === 'live' ? 'live' : 'host', + acceptedInputSources: state.optionsAnswerPresent + ? state.acceptedInputSources + : { + ...(namesDefault ? { model: 'policy', effort: 'policy' } : {}), + fastMode: 'policy', + ...Object.fromEntries( + Object.entries(state.acceptedInputSources ?? {}).filter( + ([, source]) => source !== 'policy' + ) + ) + } } } @@ -185,12 +202,14 @@ export function applyStructuredAgentSessionOptions( ) return { ...state, + optionsAnswerPresent: true, catalog: installed ? state.catalog : structuredAgentSessionOptionCatalog(seed, result), fastModeRestricted: installed && result.fastModeSupport?.supported === false && result.fastModeSupport.reason !== 'model-not-supported', catalogSource: installed && state.catalogSource === 'seed' ? 'seed' : 'live', + acceptedInputSources: current.acceptedInputSources, olderHost: current.defaulted === undefined ? true : undefined, defaultsUnsent: current.defaulted !== undefined && !launch.newLaunch ? true : undefined, verifiedModelIds: installed @@ -216,7 +235,8 @@ function structuredAgentSessionControlsModelId( export function commitStructuredAgentSessionOption( state: StructuredAgentSessionOptionState, id: string, - value: string + value: string, + origin: 'picked' | 'seeded' = 'picked' ): StructuredAgentSessionOptionState { if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { return { ...state, permissionMode: value, reportedPermissionMode: undefined, pendingId: null } @@ -230,18 +250,26 @@ export function commitStructuredAgentSessionOption( } // Keep other picks after a model change; the host reconciles independent values. state.record.values[id] = { value: decoded, source: 'dispatched' } - return { ...state, pendingId: null } + return { + ...state, + acceptedInputSources: + state.acceptedInputSources === undefined + ? undefined + : { ...state.acceptedInputSources, [id]: origin }, + pendingId: null + } } export function commitStructuredAgentSessionOptionValues( state: StructuredAgentSessionOptionState, - values: Readonly> + values: Readonly>, + origin: 'picked' | 'seeded' = 'picked' ): StructuredAgentSessionOptionState { let next = state for (const id of STRUCTURED_LAUNCH_OPTION_IDS) { const value = values[id] if (value !== undefined) { - next = commitStructuredAgentSessionOption(next, id, value) + next = commitStructuredAgentSessionOption(next, id, value, origin) } } return next diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index 83d15b2b089..df2a2d9187d 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionOptionsResult } from './agent-session-wire' import { sortNativeChatSessionOptions } from './native-chat/input/native-chat-session-option-snapshot' import { + canSetStructuredAgentSessionOption, applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, @@ -65,6 +66,43 @@ describe('the permission mode picker', () => { ).toMatchObject({ settable: false, kind: { currentValue: undefined } }) }) + it('admits supported Own reset through the same descriptor/readiness predicate', () => { + const state = { ...live('codex', { permissionMode: 'ask' }), permissionOwnSupported: true } + expect(canSetStructuredAgentSessionOption(state, 'permissionMode', 'own')).toBe(true) + for (const permissionOwnSupported of [false, null]) { + const unsupported = { ...state, permissionOwnSupported } + expect(canSetStructuredAgentSessionOption(unsupported, 'permissionMode', 'own')).toBe(false) + expect(canSetStructuredAgentSessionOption(unsupported, 'permissionMode', 'full-access')).toBe( + permissionOwnSupported === false + ) + } + expect(canSetStructuredAgentSessionOption(state, 'permissionMode', 'invalid')).toBe(false) + expect(canSetStructuredAgentSessionOption(state, 'permissionMode', 'own', [], true)).toBe(false) + expect( + canSetStructuredAgentSessionOption( + state, + 'permissionMode', + 'own', + structuredAgentSessionOptionSnapshot(state), + false + ) + ).toBe(false) + expect( + canSetStructuredAgentSessionOption( + { ...state, pendingId: 'permissionMode' }, + 'permissionMode', + 'own' + ) + ).toBe(false) + expect( + canSetStructuredAgentSessionOption( + { ...state, reportedPermissionMode: null }, + 'permissionMode', + 'own' + ) + ).toBe(false) + }) + it('shows the seeded mode before the agent starts, beside the quiet model pill', () => { const state = createStructuredAgentSessionOptionState( 'codex', diff --git a/src/shared/structured-agent-session-selection-operations.test.ts b/src/shared/structured-agent-session-selection-operations.test.ts new file mode 100644 index 00000000000..bdaf71a8d0b --- /dev/null +++ b/src/shared/structured-agent-session-selection-operations.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { createStructuredSessionSelectionController } from './structured-agent-session-selection-operations' + +// Creation, attachment and revalidation share identities even when two picks have equal values. +describe('chat selection operation handoff', () => { + it('freezes opening identities, supersedes unsent holds and issues attached picks before old replies', async () => { + const remember = vi.fn() + const controller = createStructuredSessionSelectionController(remember) + const opening = controller.issue('model', 'opus', null) + const frozen = controller.freeze() + const model = controller.issue('model', 'sonnet', null) + const oldEffort = controller.issue('effort', 'high', 'sonnet') + const obsolete = controller.issue('effort', 'low', 'sonnet') + await expect(oldEffort.result).resolves.toEqual({ kind: 'superseded' }) + const modelReply = Promise.withResolvers() + const send = vi.fn(async (op) => { + if (op === model) { + await modelReply.promise + } + return { kind: 'accepted' as const, options: { [op.key]: op.value } } + }) + controller.flush(send) + const sending = controller.drain() + await Promise.resolve() + const newerEffort = controller.issue('effort', 'low', 'sonnet') + const newerModel = controller.issue('model', 'opus', null) + const fast = controller.issue('fastMode', 'true', 'opus') + void controller.flush(send) + controller.settle(frozen[0]!, { kind: 'accepted', options: { model: 'opus' } }) + expect(frozen).toEqual([opening]) + expect(controller.overlay()).toEqual({ model: 'opus', effort: 'low', fastMode: 'true' }) + expect(controller.pending()).toContain(newerModel) + expect(send.mock.calls.map(([op]) => op)).toEqual([ + model, + obsolete, + newerModel, + newerEffort, + fast + ]) + const beforeCompletion = controller.captureRead() + modelReply.resolve() + await sending + expect(send.mock.calls.map(([op]) => op)).toEqual([ + model, + obsolete, + newerModel, + newerEffort, + fast + ]) + controller.retire(beforeCompletion) + expect(controller.overlay()).toEqual({ model: 'opus', effort: 'low', fastMode: 'true' }) + // The next complete answer can contain another client's newer values; equality is irrelevant. + controller.retire(controller.captureRead()) + expect(controller.overlay()).toEqual({}) + expect( + remember.mock.calls + .flatMap(([picks]) => picks) + .some((pick) => pick.optionId === 'effort' && pick.value === 'high') + ).toBe(false) + }) + + it('orders accepted defaults independently by model and field without letting an old reply adopt its model', () => { + const remember = vi.fn() + const controller = createStructuredSessionSelectionController(remember) + const oldEffort = controller.issue('effort', 'low', 'sonnet') + oldEffort.transmitted = true + const newModel = controller.issue('model', 'opus', null) + const fast = controller.issue('fastMode', 'true', 'opus') + const newEffort = controller.issue('effort', 'high', 'sonnet') + controller.settle(fast, { kind: 'accepted', options: { fastMode: 'true' } }) + controller.settle(newModel, { kind: 'accepted', options: { model: 'opus' } }) + controller.settle(newEffort, { + kind: 'accepted', + options: { model: 'unrelated-later-model', effort: 'high' } + }) + controller.settle(oldEffort, { kind: 'accepted', options: { effort: 'low' } }) + expect(remember.mock.calls.flatMap(([picks]) => picks)).toEqual([ + { modelId: 'opus', optionId: 'fastMode', value: true }, + { modelId: 'opus', optionId: 'model', value: 'opus', adoptModelAsLaunchDefault: false }, + { modelId: 'sonnet', optionId: 'effort', value: 'high' } + ]) + }) +}) + +it('drains abandoned writes without cancelling host acceptance or remembering late replies', async () => { + const remember = vi.fn() + const controller = createStructuredSessionSelectionController(remember) + const reply = Promise.withResolvers<{ kind: 'accepted'; options: Record }>() + const operation = controller.issue('model', 'sonnet', null) + controller.flush(() => reply.promise) + const drained = controller.drain() + controller.abandon() + await drained + await expect(operation.result).resolves.toEqual({ kind: 'superseded' }) + reply.resolve({ kind: 'accepted', options: { model: 'sonnet' } }) + await reply.promise + await Promise.resolve() + expect(remember).not.toHaveBeenCalled() + expect(controller.overlay()).toEqual({}) + expect(controller.captureRead()).toEqual([]) + expect(controller.isPending('model')).toBe(false) + expect(controller.pendingId()).toBeNull() +}) diff --git a/src/shared/structured-agent-session-selection-operations.ts b/src/shared/structured-agent-session-selection-operations.ts new file mode 100644 index 00000000000..0a28e09d254 --- /dev/null +++ b/src/shared/structured-agent-session-selection-operations.ts @@ -0,0 +1,275 @@ +import { + STRUCTURED_LAUNCH_OPTION_IDS, + STRUCTURED_LAUNCH_SEED_OPTION_IDS +} from './native-chat/input/native-chat-session-option-defaults' +import { decodeStructuredAgentSessionOptionValue } from './structured-agent-session-option-codec' +import type { NativeChatSessionOptionSettingsMutation } from './native-chat/input/native-chat-session-options' +import type { AgentSessionWriteFailure } from './agent-session-write-failure' + +export type SelectionOutcome = + | { kind: 'accepted'; options: Readonly> } + | { kind: 'refused'; failure?: AgentSessionWriteFailure } + | { kind: 'superseded' } + | { kind: 'unknown'; failure?: AgentSessionWriteFailure } + +const OPTION_ORDER: readonly string[] = STRUCTURED_LAUNCH_OPTION_IDS + +type RememberedPicks = Extract< + NativeChatSessionOptionSettingsMutation, + { type: 'apply-picks' } +>['picks'] +export type SelectionOperation = { + identity: number + key: string + value: string + model: string | null + transmitted: boolean + outcome?: SelectionOutcome + request?: Promise + result: Promise + resolve: (outcome: SelectionOutcome) => void + previousPending?: SelectionOperation + nextPending?: SelectionOperation +} + +/** Finite operation precedence above a queried host answer; no mutation reply installs a base. */ +export function createStructuredSessionSelectionController( + remember: (picks: RememberedPicks) => void, + isCurrent: () => boolean = () => true +) { + let sequence = 0 + let revision = 0 + let adopted = 0 + const inFlight = new Set>() + const operations = new Map() + const latest = new Map() + const remembered = new Map() + const terminal = new Set() + let terminalRevision = 0 + const pendingCounts = new Map() + let pendingTail: SelectionOperation | undefined + const currentOperations = () => [...latest.values()].flatMap((id) => operations.get(id) ?? []) + const listeners = new Set<() => void>() + const notify = () => { + revision += 1 + for (const listener of listeners) { + listener() + } + } + const settle = (operation: SelectionOperation, outcome: SelectionOutcome): void => { + if (operation.outcome) { + return + } + if (!isCurrent()) { + outcome = { kind: 'superseded' } + } + operation.outcome = outcome + pendingCounts.set(operation.key, (pendingCounts.get(operation.key) ?? 1) - 1) + if (operation.previousPending) { + operation.previousPending.nextPending = operation.nextPending + } + if (operation.nextPending) { + operation.nextPending.previousPending = operation.previousPending + } else { + pendingTail = operation.previousPending + } + operation.previousPending = undefined + operation.nextPending = undefined + operation.resolve(outcome) + if (outcome.kind === 'accepted' || outcome.kind === 'unknown') { + terminal.add(operation.identity) + terminalRevision = revision + 1 + } + if (outcome.kind === 'accepted') { + const model = + operation.key === 'model' ? (outcome.options.model ?? operation.model) : operation.model + const value = decodeStructuredAgentSessionOptionValue( + operation.key, + outcome.options[operation.key] ?? operation.value + ) + const key = operation.key === 'model' ? 'model' : JSON.stringify([model, operation.key]) + if ( + model && + value !== null && + STRUCTURED_LAUNCH_SEED_OPTION_IDS.some((id) => id === operation.key) && + (remembered.get(key) ?? 0) < operation.identity + ) { + remembered.set(key, operation.identity) + const adopt = adopted <= operation.identity + adopted = Math.max(adopted, operation.identity) + try { + remember([ + { + modelId: model, + optionId: operation.key, + value, + ...(!adopt ? { adoptModelAsLaunchDefault: false } : {}) + } + ]) + } catch { + /* Remembering never gates selection. */ + } + } + } else if (outcome.kind !== 'unknown') { + operations.delete(operation.identity) + } + notify() + } + const transmit = ( + operation: SelectionOperation, + send: (operation: SelectionOperation) => Promise + ): Promise => { + if (!isCurrent() || operation.outcome || operation.transmitted) { + return operation.request ?? operation.result + } + operation.transmitted = true + inFlight.add(operation.result) + void operation.result.then(() => inFlight.delete(operation.result)) + const request = (async () => send(operation))() + operation.request = request + void request.then( + (outcome) => settle(operation, outcome), + () => settle(operation, { kind: 'unknown' }) + ) + notify() + return request + } + return { + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + snapshot: () => revision, + issue( + key: string, + value: string, + model: string | null, + offers?: (key: string, value: string) => boolean + ): SelectionOperation { + if (!isCurrent()) { + const outcome = { kind: 'superseded' } as const + return { + identity: 0, + key, + value, + model, + transmitted: false, + outcome, + result: Promise.resolve(outcome), + resolve: () => {} + } + } + for (const previous of currentOperations()) { + if ( + !previous.transmitted && + !previous.outcome && + (previous.key === key || + (key === 'model' && offers && !offers(previous.key, previous.value))) + ) { + settle(previous, { kind: 'superseded' }) + } + } + const reply = Promise.withResolvers() + const operation: SelectionOperation = { + identity: ++sequence, + key, + value, + model: key === 'model' ? value : model, + transmitted: false, + result: reply.promise, + resolve: reply.resolve + } + operation.previousPending = pendingTail + if (pendingTail) { + pendingTail.nextPending = operation + } + pendingTail = operation + pendingCounts.set(key, (pendingCounts.get(key) ?? 0) + 1) + operations.set(operation.identity, operation) + latest.set(key, operation.identity) + notify() + return operation + }, + overlay(): Record { + return Object.fromEntries( + currentOperations().map((op) => [ + op.key, + op.outcome?.kind === 'accepted' ? (op.outcome.options[op.key] ?? op.value) : op.value + ]) + ) + }, + current: (key: string) => operations.get(latest.get(key) ?? -1), + transmit, + pending(): SelectionOperation[] { + return [...operations.values()].filter((op) => !op.outcome) + }, + isPending: (key: string) => (pendingCounts.get(key) ?? 0) > 0, + pendingId: () => pendingTail?.key ?? null, + freeze(replaced: readonly SelectionOperation[] = []): SelectionOperation[] { + if (!isCurrent()) { + return [] + } + // A definitive create refusal permits a new capture; an ambiguous retry never calls freeze. + for (const op of replaced) { + if (!op.outcome && operations.get(op.identity) === op) { + if (latest.get(op.key) === op.identity) { + op.transmitted = false + } else { + settle(op, { kind: 'superseded' }) + } + } + } + const frozen = currentOperations().filter((op) => !op.transmitted && !op.outcome) + for (const op of frozen) { + op.transmitted = true + } + return frozen + }, + settle, + terminalSnapshot: () => (terminal.size ? terminalRevision : 0), + captureRead(): number[] { + return [...terminal] + }, + retire(identities: readonly number[]): void { + for (const identity of identities) { + operations.delete(identity) + terminal.delete(identity) + } + if (identities.length) { + terminalRevision = revision + 1 + notify() + } + }, + flush(send: (operation: SelectionOperation) => Promise): void { + if (!isCurrent()) { + return + } + const unsent = currentOperations().filter((op) => !op.transmitted && !op.outcome) + unsent.sort((a, b) => OPTION_ORDER.indexOf(a.key) - OPTION_ORDER.indexOf(b.key)) + for (const operation of unsent) { + void transmit(operation, send) + } + }, + async drain(): Promise { + while (inFlight.size) { + await Promise.all(inFlight) + } + }, + abandon(): void { + for (const operation of operations.values()) { + settle(operation, { kind: 'superseded' }) + } + operations.clear() + latest.clear() + remembered.clear() + terminal.clear() + pendingCounts.clear() + notify() + } + } +} +export type StructuredSessionSelectionController = ReturnType< + typeof createStructuredSessionSelectionController +> diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index cafd5e1f72c..d7bb1717482 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -15,6 +15,7 @@ import { structuredAgentSessionPayloadFingerprint } from './structured-agent-ses export type StructuredAgentSessionSendControl = { delivery?: 'queue-if-active' options?: Readonly> + selection?: Readonly> usageLimitContinuationOfTurnItemId?: string } @@ -29,6 +30,7 @@ export function agentSessionSendControlFields( return { ...(send.delivery ? { delivery: send.delivery } : {}), ...(send.options ? { options: send.options } : {}), + ...(send.selection !== undefined ? { selection: send.selection } : {}), ...(send.usageLimitContinuationOfTurnItemId ? { usageLimitContinuationOfTurnItemId: send.usageLimitContinuationOfTurnItemId } : {}) diff --git a/tests/e2e/runtime-environment-selection-affinity.unit.test.ts b/tests/e2e/runtime-environment-selection-affinity.unit.test.ts new file mode 100644 index 00000000000..f5431ed8b26 --- /dev/null +++ b/tests/e2e/runtime-environment-selection-affinity.unit.test.ts @@ -0,0 +1,187 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as environmentStore from '../../src/shared/runtime-environment-store' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../src/shared/protocol-version' +import { callRuntimeEnvironment } from '../../src/main/ipc/runtime-environments/runtime-environment-transport-routing' +import { + structuredSessionSelectionController, + forgetStructuredSessionSelection, + registerStructuredSelectionCatalogStore +} from '@/lib/structured-agent-session-selection-controller' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { resetRuntimeEnvironmentStatusOwners } from '../../src/main/ipc/runtime-environments/runtime-environment-request-connections' +import { pairingCode } from '../../src/main/ipc/runtime-environments/runtime-environments-ipc-test-harness' + +const { sendRemoteRuntimeRequestMock, getPathMock } = vi.hoisted(() => ({ + sendRemoteRuntimeRequestMock: vi.fn(), + getPathMock: vi.fn() +})) + +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + app: { getPath: getPathMock }, + ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn(), removeAllListeners: vi.fn() } +})) +vi.mock('../../src/shared/remote-runtime/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock, + subscribeRemoteRuntimeRequest: vi.fn() +})) +vi.mock( + '../../src/main/ipc/runtime-environments/runtime-environment-request-connections', + async () => { + const { withRuntimeStatusOwners } = + await import('../../src/main/ipc/runtime-environments/runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: vi.fn(), + subscribeRemoteRuntimeSharedControlRequest: vi.fn(), + getRemoteRuntimeSharedControlDiagnostics: vi.fn(() => null), + reconnectRemoteRuntimeSharedControlConnection: vi.fn(), + retryRemoteRuntimeSharedControlConnectionsNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: vi.fn() + }) + } +) + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-selection-affinity-')) + getPathMock.mockReturnValue(userDataPath) +}) +afterEach(() => { + vi.restoreAllMocks() + resetRuntimeEnvironmentStatusOwners() + vi.unstubAllGlobals() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +it('fences deferred selection at the real queue and follows only proved pairing continuity, including late memory', async () => { + const original = environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'selection-owner', + pairingCode: pairingCode(), + now: 1 + }) + const target = { kind: 'environment', environmentId: original.id } as const + replaceRuntimeEnvironmentRevisions([original]) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: (args: { + selector: string + method: string + params?: unknown + expectedEnvironmentPairingRevision?: number + }) => + callRuntimeEnvironment( + userDataPath, + args.selector, + args.method, + args.params, + undefined, + args.expectedEnvironmentPairingRevision + ) + } + } + }) + const restoreCatalog = registerStructuredSelectionCatalogStore({ + read: () => ({ runtimeEnvironmentCatalogHydrated: true }), + subscribe: () => () => {} + }) + try { + const owner = structuredSessionSelectionController(target, 'selection-queue', 'codex') + const release = Promise.withResolvers() + sendRemoteRuntimeRequestMock.mockImplementation(async (_connection, method) => { + if (method === 'files.read') { + await release.promise + } + return { + id: method, + ok: true, + result: + method === 'status.get' + ? { + runtimeId: 'host', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : {}, + _meta: { runtimeId: 'host' } + } + }) + const occupying = Array.from({ length: 8 }, () => + callRuntimeEnvironment(userDataPath, original.id, 'files.read', {}) + ) + await vi.waitFor(() => + expect( + sendRemoteRuntimeRequestMock.mock.calls.filter(([, method]) => method === 'files.read') + ).toHaveLength(8) + ) + const sendPick = (options: { expectedEnvironmentPairingRevision?: number }) => + callRuntimeRpc( + target, + 'agentSession.setOption', + {}, + { ...options, skipCompatibilityCheck: true } + ) + const queued = owner.request(sendPick) + const changed = environmentStore.updateEnvironmentFromPairingCode(userDataPath, original.id, { + pairingCode: pairingCode(), + now: 2 + }) + replaceRuntimeEnvironmentRevisions([changed], { + retired: [], + sameHost: [], + pending: [{ id: original.id, fromRevision: 1 }] + }) + release.resolve() + await Promise.all(occupying) + await expect(queued).rejects.toMatchObject({ code: 'runtime_environment_changed' }) + expect( + sendRemoteRuntimeRequestMock.mock.calls.some( + ([, method]) => method === 'agentSession.setOption' + ) + ).toBe(false) + expect(structuredSessionSelectionController(target, 'selection-queue', 'codex')).toBe(owner) + const accepted = owner.issue('model', 'gpt-5.4', null) + owner.settle(accepted, { kind: 'accepted', options: { model: 'gpt-5.4' } }) + // Memory starts with the original revision too; the pending replacement cannot receive it. + await Promise.resolve() + expect( + sendRemoteRuntimeRequestMock.mock.calls.some( + ([, method]) => method === 'settings.mutateNativeChatSessionOptions' + ) + ).toBe(false) + replaceRuntimeEnvironmentRevisions([changed], { + retired: [], + sameHost: [{ id: original.id, fromRevision: 1, toRevision: changed.pairingRevision! }] + }) + await owner.request(sendPick) + const later = owner.issue('model', 'gpt-5.3-codex', null) + owner.settle(later, { kind: 'accepted', options: { model: 'gpt-5.3-codex' } }) + await vi.waitFor(() => + expect( + sendRemoteRuntimeRequestMock.mock.calls.filter( + ([, method]) => method === 'settings.mutateNativeChatSessionOptions' + ) + ).toHaveLength(1) + ) + forgetStructuredSessionSelection('selection-queue', target) + const count = sendRemoteRuntimeRequestMock.mock.calls.length + await expect( + owner.request((options) => callRuntimeRpc(target, 'agentSession.setOption', {}, options)) + ).rejects.toThrow('retired') + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(count) + } finally { + forgetStructuredSessionSelection('selection-queue', target) + replaceRuntimeEnvironmentRevisions([]) + restoreCatalog() + } +}) diff --git a/tests/e2e/structured-permission-report-publication.unit.test.ts b/tests/e2e/structured-permission-report-publication.unit.test.ts index 0a5473e165d..be6c7e73054 100644 --- a/tests/e2e/structured-permission-report-publication.unit.test.ts +++ b/tests/e2e/structured-permission-report-publication.unit.test.ts @@ -6,7 +6,7 @@ import { import { structuredAgentRuntimeRegistration } from '../../src/main/runtime/structured-agent-runtime-registrations' import { createStructuredAgentEnvironmentResolvers } from '../../src/main/runtime/structured-agent-shell-environment' import { act, renderHook, waitFor, cleanup } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { SDKStatusMessage } from '@anthropic-ai/claude-agent-sdk' const transport = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -33,7 +33,10 @@ import { envelope, CALLER } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness' -import { HOST_TEST_SESSION as sessionId } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as sessionId +} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY } from '../../src/shared/agent-session-permission-modes-capability' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' @@ -44,6 +47,16 @@ import { GROK_CONFIG_OPTIONS } from '../../src/main/acp/acp-structured-adapter.t import { closeProviderTimelineRigs } from '../../src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { agentSessionRefusalFailure } from '../../src/shared/agent-session-write-failure' +import { forgetStructuredSessionSelection } from '@/lib/structured-agent-session-selection-controller' + +beforeEach(() => vi.spyOn(Date, 'now').mockReturnValue(HOST_TEST_NOW)) +afterEach(() => { + cleanup() + forgetStructuredSessionSelection(sessionId) + vi.restoreAllMocks() +}) + afterEach(closeProviderTimelineRigs) function permissionConsumersFor( @@ -63,13 +76,16 @@ function permissionConsumersFor( transport.subscribe.mockImplementation(async (_target, emit) => ({ unsubscribe: subscribe(emit) })) - const read = vi.fn(async (method: string) => { + const read = vi.fn(async (method: string, params?: Parameters[1]) => { + if (method === 'agentSession.setOption' && params) { + return write(method, method, { key: params.key, value: params.value }, params) + } if (method !== 'agentSession.options') { return new Promise(() => {}) } return host.readOptions(sessionId) }) - transport.call.mockImplementation((_target, method) => read(method)) + transport.call.mockImplementation((_target, method, params) => read(method, params)) const client: RpcClient = { getState: () => 'connected', onStateChange: () => () => {}, @@ -83,23 +99,28 @@ function permissionConsumersFor( } const write = vi.fn() write.mockImplementation( - async (_method, _fingerprint, fields: { key: string; value: string }) => { - const result = await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - if (!result.ok) { - throw new Error(`Permission pick refused: ${result.refusal.code}`) - } - return result.value + async ( + _method: string, + _fingerprint: string, + fields: { key: string; value: string }, + params?: Parameters[1] + ) => { + return host.setOption( + CALLER, + params ?? { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + ) } ) const phoneWrite = vi.fn() - phoneWrite.mockImplementation(async (...args) => ({ - status: 'accepted', - value: await write(...args), - sameFence: true - })) + phoneWrite.mockImplementation(async (...args) => { + const result = await write(...args) + return result.ok + ? { status: 'accepted', value: result.value, sameFence: true } + : { status: 'rejected', failure: agentSessionRefusalFailure(result.refusal) } + }) const target = { kind: 'local' } as const const common = { agent, sessionId, fence } const hooks = [ @@ -127,7 +148,8 @@ function permissionConsumersFor( permissionModes: true, permissionOwn: true, statusFeed: true, - coherentModelCatalog: false + coherentModelCatalog: false, + sendOptions: false }, mutate: phoneWrite }) @@ -283,7 +305,12 @@ it.each([1, 10, 100])( throw new Error('Both mounted permission consumers are required') } await act(async () => { - expect(await desktop.result.current.setStructuredOption('permissionMode', 'ask')).toBe(true) + const applied = await desktop.result.current.setStructuredOption('permissionMode', 'ask') + expect( + write.mock.settledResults.at(-1)?.value, + JSON.stringify(write.mock.settledResults.at(-1)?.value) + ).toMatchObject({ ok: true }) + expect(applied).toBe(true) }) expect(store.getRecord(sessionId)?.permissionMode).toBe('ask') expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ @@ -514,7 +541,7 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta await waitFor(() => expect(host.readStatusSummary(sessionId)?.hostExecutionPhase).toBe('ready')) const fence = store.getRecord(sessionId)!.lease.runtimeFence const child = host.collaboratorsForTests().sessions.get(sessionId)!.child! - const { mount } = permissionConsumersFor(host, 'codex', fence) + const { mount, write } = permissionConsumersFor(host, 'codex', fence) const views = mount() const descriptions = () => views.map( @@ -524,7 +551,12 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta await waitFor(() => expect(descriptions()).toEqual(['Approve for me', 'Approve for me'])) const desktop = views[0]! await act(async () => { - expect(await desktop.result.current.setStructuredOption('permissionMode', 'ask')).toBe(true) + const applied = await desktop.result.current.setStructuredOption('permissionMode', 'ask') + expect( + write.mock.settledResults.at(-1)?.value, + JSON.stringify(write.mock.settledResults.at(-1)?.value) + ).toMatchObject({ ok: true }) + expect(applied).toBe(true) }) const saved = structuredClone(store.getRecord(sessionId)) const readCurrentOptions = adapter.readOptions!.bind(adapter, { sessionId, fence })