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 })