diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index ba34a82c73b2..fcf21463b715 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -122,6 +122,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts', @@ -137,6 +138,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts', @@ -167,6 +169,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 5e933c94e49b..1d50b12df350 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -90,6 +90,7 @@ const HOST_SUPPORT = { queuedMessages: true, queuedCommands: false, statusFeed: true, + messageChoices: false, quietRepeatedStop: true } diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index e5debf14376a..f0592549965c 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -15,6 +16,7 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: false, questionAnswers: false, queuedMessages: false, + messageChoices: false, queuedCommands: false, quietRepeatedStop: false, statusFeed: false @@ -29,6 +31,9 @@ describe('structuredAgentSessionHostSupport', () => { expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) ).toEqual({ ...none, queuedMessages: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, messageChoices: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY]) ).toEqual({ ...none, queuedCommands: true }) 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 84c329e63738..f672c0352aee 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -3,6 +3,7 @@ import { AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -13,6 +14,7 @@ export type StructuredAgentSessionHostSupport = { questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ queuedMessages: boolean + messageChoices: boolean /** A /compact sent while the agent works waits as a card; an older host refuses it. */ queuedCommands: boolean /** The host publishes every session's status on one stream. A host from before phones could @@ -29,6 +31,7 @@ export function structuredAgentSessionHostSupport( promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), + messageChoices: capabilities.includes(AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY), queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY), statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY) diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7b2da967d56c..fc2877221b82 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -125,6 +125,8 @@ export async function requestStructuredAgentSessionMutation(args: { sessionId: string expectedRuntimeFence: number fields: Record + /** Sent beside the request, excluded from the operation fingerprint. */ + unfingerprintedFields?: Record clientOperationId?: string timeoutMs?: number }): Promise> { @@ -135,6 +137,7 @@ export async function requestStructuredAgentSessionMutation(args: { sessionId, expectedRuntimeFence, fields, + unfingerprintedFields, clientOperationId, timeoutMs } = args @@ -153,7 +156,8 @@ export async function requestStructuredAgentSessionMutation(args: { fields }) }, - ...fields + ...fields, + ...unfingerprintedFields }, timeoutMs ) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index e2e1ebccbe03..d48fccfb03e2 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -1,4 +1,5 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import type { AgentSessionExecutionOptions } from '../../../src/shared/agent-session-execution-options' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -20,6 +21,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { attachments: readonly StructuredAgentSessionAttachment[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions deadline?: number /** The id the host records the message under. */ clientMessageId: string @@ -41,6 +43,9 @@ export async function sendMobileStructuredAgentSessionMessage(input: { body: structuredAgentSessionSendBody(input.text, input.attachments), ...(input.delivery ? { delivery: input.delivery } : {}) }, + ...(input.executionOptions + ? { unfingerprintedFields: { executionOptions: input.executionOptions } } + : {}), clientOperationId: input.clientMessageId, timeoutMs }) diff --git a/mobile/src/session/mobile-structured-option-snapshot.ts b/mobile/src/session/mobile-structured-option-snapshot.ts new file mode 100644 index 000000000000..0ef50143b92c --- /dev/null +++ b/mobile/src/session/mobile-structured-option-snapshot.ts @@ -0,0 +1,27 @@ +import { createNativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' +import { + commitStructuredAgentSessionOptionValues, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../../src/shared/structured-agent-session-options' + +/** The host's saved choice with this phone's still-pending pick on top. */ +export function mobileStructuredOptionSnapshot( + agent: string, + state: StructuredAgentSessionOptionState, + hostSelection: Readonly> | undefined +): SessionOptionDescriptor[] { + const selected = hostSelection + ? commitStructuredAgentSessionOptionValues( + { ...state, record: createNativeChatSessionOptionRecord(agent) }, + hostSelection + ) + : state + const pending = state.pendingPick + return structuredAgentSessionOptionSnapshot( + pending + ? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value }) + : selected + ) +} 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 0779304fb026..2e6630465146 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 @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' @@ -9,7 +9,10 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o const HOST_LIST: AgentSessionModelCatalogResult = { origin: 'probe', - models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], + models: [ + { id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }, + { id: 'gpt-alt', label: 'GPT Alt', isDefault: false, efforts: [] } + ], fetchedAt: 1_000 } @@ -36,11 +39,14 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] } -type Props = { rpc: RpcClient; fence: number } +type Props = { rpc: RpcClient; fence: number; hostSelection?: Readonly> } async function mount(initial: Props) { const frames: string[][] = [] - const mutate: StructuredAgentSessionMutate = vi.fn() + const snapshots: SessionOptionDescriptor[][] = [] + const mutate: StructuredAgentSessionMutate = async () => new Promise(() => {}) + let pick: ReturnType['setStructuredOption'] = async () => + false function Probe(props: Props): null { const options = useMobileStructuredAgentOptions({ agent: 'codex', @@ -48,9 +54,12 @@ async function mount(initial: Props) { sessionId: 'session-1', enabled: true, fence: props.fence, + hostSelection: props.hostSelection, mutate }) frames.push(modelChoices(options.optionSnapshot)) + snapshots.push(options.optionSnapshot) + pick = options.setStructuredOption return null } let renderer: ReactTestRenderer | null = null @@ -59,6 +68,8 @@ async function mount(initial: Props) { }) return { frames, + snapshots, + pick: (id: string, value: string) => pick(id, value), update: (next: Props) => act(async () => { renderer?.update(createElement(Probe, next)) @@ -74,10 +85,10 @@ describe('the phone picker’s first frame', () => { const harness = await mount({ rpc, fence: 1 }) expect(harness.frames.every((choices) => choices.length === 0)).toBe(true) await act(async () => answer(HOST_LIST)) - expect(harness.frames.at(-1)).toEqual(['gpt-host']) + expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt']) // Only the host's list was ever offered. expect(harness.frames.filter((choices) => choices.length > 0)).toEqual( - harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host']) + harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host', 'gpt-alt']) ) await harness.unmount() }) @@ -86,7 +97,7 @@ describe('the phone picker’s first frame', () => { let reads = 0 const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {}))) const harness = await mount({ rpc, fence: 1 }) - expect(harness.frames.at(-1)).toEqual(['gpt-host']) + expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt']) const before = harness.frames.length await harness.update({ rpc, fence: 2 }) expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true) @@ -99,4 +110,23 @@ describe('the phone picker’s first frame', () => { expect(harness.frames.at(-1)?.length).toBeGreaterThan(0) await harness.unmount() }) + + it('uses the host’s live selection when the options read has not answered', async () => { + const rpc = client(() => Promise.resolve(HOST_LIST)) + const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } }) + const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model') + expect(model?.kind.currentValue).toBe('gpt-host') + await harness.unmount() + }) + + it('includes a pick still waiting for the host in the next message snapshot', async () => { + const rpc = client(() => Promise.resolve(HOST_LIST)) + const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } }) + await act(async () => { + void harness.pick('model', 'gpt-alt') + }) + const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model') + expect(model?.kind.currentValue).toBe('gpt-alt') + await harness.unmount() + }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 63a434f90b1c..d47f0d60fb4f 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -34,6 +34,7 @@ import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' +import { mobileStructuredOptionSnapshot } from './mobile-structured-option-snapshot' type StructuredOptionsController = { optionPickerRequest: { id: string; sequence: number } | null @@ -56,8 +57,9 @@ export function useMobileStructuredAgentOptions(args: { mutate: StructuredAgentSessionMutate /** Moves when the host's status says a session option changed; each move re-reads them. */ optionStamp?: string + hostSelection?: Readonly> }): StructuredOptionsController { - const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args + const { agent, client, enabled, fence, mutate, optionStamp, hostSelection, sessionId } = args // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. const optionCatalog = useMemo( () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), @@ -68,7 +70,6 @@ export function useMobileStructuredAgentOptions(args: { ) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -106,7 +107,6 @@ export function useMobileStructuredAgentOptions(args: { // The chat's own, which a restart for a new mode keeps: the pill never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 - pendingOptionRef.current = null optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) @@ -173,8 +173,8 @@ export function useMobileStructuredAgentOptions(args: { ]) const optionSnapshot = useMemo( - () => structuredAgentSessionOptionSnapshot(optionState), - [optionState] + () => mobileStructuredOptionSnapshot(agent ?? 'codex', optionState, hostSelection), + [agent, hostSelection, optionState] ) const setStructuredOption = useCallback( @@ -182,7 +182,6 @@ export function useMobileStructuredAgentOptions(args: { const currentState = optionStateRef.current const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( - pendingOptionRef.current !== null || !client || !sessionId || !optionCatalog || @@ -204,10 +203,8 @@ export function useMobileStructuredAgentOptions(args: { ? commitStructuredAgentSessionOption(current, id, encoded) : current ) - } else { - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) } + updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } })) // A same-fence accepted write, or a shown one that failed, reads back what the host now holds. let readsBack = shownAtOnce try { @@ -266,10 +263,9 @@ export function useMobileStructuredAgentOptions(args: { .catch(() => undefined) } if (isCurrent()) { - pendingOptionRef.current = null updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } + current.record === targetRecord && current.pendingPick?.id === id + ? { ...current, pendingPick: null } : current ) } @@ -316,7 +312,7 @@ export function useMobileStructuredAgentOptions(args: { (agent !== null && isAgentSessionHandleProvider(agent)), optionSnapshot, optionSurface, - pendingOptionId: optionState.pendingId, + pendingOptionId: optionState.pendingPick?.id ?? null, setStructuredOption, invokeStructuredOption } diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 7a14895fd66e..d46c48b8590d 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -124,6 +124,7 @@ function Harness({ queuedMessages: false, queuedCommands: false, statusFeed: false, + messageChoices: false, quietRepeatedStop: false }, onSendError: vi.fn() diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index 7a0c57b9b352..41868ff7e11f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -18,6 +18,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { queuedMessages: true, queuedCommands: false, statusFeed: false, + messageChoices: false, quietRepeatedStop: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 4b11ee4fd582..c9ddffa50557 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -25,7 +25,7 @@ import { useMobileStructuredSessionHostStopping } from './use-mobile-structured- import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' -import { useMobileStructuredSessionOptionStamp } from './use-mobile-structured-session-option-stamp' +import { useMobileStructuredSessionOptionStatus } from './use-mobile-structured-session-option-status' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { pendingStructuredPromptIdentity, @@ -93,7 +93,7 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const optionStamp = useMobileStructuredSessionOptionStamp({ + const optionStatus = useMobileStructuredSessionOptionStatus({ client, sessionId, enabled: enabled && connected && hostSupport?.statusFeed === true @@ -105,7 +105,8 @@ export function useMobileStructuredAgentSession(args: { enabled, fence: state.fence, mutate, - optionStamp + optionStamp: optionStatus.stamp, + hostSelection: optionStatus.selection }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options @@ -185,6 +186,7 @@ export function useMobileStructuredAgentSession(args: { sessionId, enabled, queueCapable, + messageChoices: hostSupport?.messageChoices === true, commandsWait, stateRef, stopping, 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 a7b3c1c32b8b..62eb2fe2a256 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -4,6 +4,7 @@ // host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' +import { agentSessionExecutionOptionsFromSnapshot } from '../../../src/shared/agent-session-execution-option-snapshot' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { @@ -44,6 +45,7 @@ export function useMobileStructuredSendWithOutcome(args: { sessionId: string | null enabled: boolean queueCapable: boolean + messageChoices: boolean /** The host holds a /compact sent while the agent works as a card. */ commandsWait: boolean stateRef: { readonly current: StructuredAgentSessionState } @@ -74,6 +76,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + messageChoices, sessionId, stateRef, stopping @@ -138,6 +141,9 @@ export function useMobileStructuredSendWithOutcome(args: { queuedAt: Date.now(), ...(stopping ? { sentWhileStopping: true as const } : {}) } + const executionOptions = messageChoices + ? agentSessionExecutionOptionsFromSnapshot(controller.snapshot) + : undefined const outcome = await sendMobileStructuredAgentSessionMessage({ client, sessionId, @@ -147,6 +153,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), + ...(executionOptions ? { executionOptions } : {}), deadline, clientMessageId: send.clientMessageId, onError: onSendError @@ -169,6 +176,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + messageChoices, sessionId, stateRef, stopping diff --git a/mobile/src/session/use-mobile-structured-sends.test.tsx b/mobile/src/session/use-mobile-structured-sends.test.tsx index 09385e83a905..cc10d90c1fd8 100644 --- a/mobile/src/session/use-mobile-structured-sends.test.tsx +++ b/mobile/src/session/use-mobile-structured-sends.test.tsx @@ -22,6 +22,8 @@ import { } from './use-mobile-structured-agent-session-queued.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { useMobileStructuredSends } from './use-mobile-structured-sends' +import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' +import { AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' const asyncStorage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) @@ -81,7 +83,7 @@ describe('a structured send in the open chat', () => { close: () => {} } - function Harness(): null { + function Harness(props: { messageChoices?: boolean }): null { hook = useMobileStructuredAgentSession({ client, sessionId: 'session-1', @@ -89,15 +91,17 @@ describe('a structured send in the open chat', () => { enabled: true, connected: true, agent: 'codex', - hostSupport: null, + hostSupport: props.messageChoices + ? structuredAgentSessionHostSupport([AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY]) + : null, onSendError }) return null } - async function mount(): Promise { + async function mount(messageChoices = false): Promise { act(() => { - renderer = create(createElement(Harness)) + renderer = create(createElement(Harness, { messageChoices })) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent())) @@ -168,6 +172,13 @@ describe('a structured send in the open chat', () => { expect(onSendError).not.toHaveBeenCalled() }) + it('omits choices when Send precedes the picker’s model answer', async () => { + await mount(true) + await send('before picker answer') + const call = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send') + expect(call?.[1]).not.toHaveProperty('executionOptions') + }) + // Same words as a not-sent row: once recorded, the resend is one bubble and hides the earlier copy. it('resends the words of a not-sent row as one bubble, with no stray copy', async () => { const failed = recorded('failed', 'rejected', { resolvedAt: 5, submittedAt: 4 }) diff --git a/mobile/src/session/use-mobile-structured-session-option-stamp.ts b/mobile/src/session/use-mobile-structured-session-option-stamp.ts deleted file mode 100644 index c1696161e89b..000000000000 --- a/mobile/src/session/use-mobile-structured-session-option-stamp.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback, useSyncExternalStore } from 'react' -import type { RpcClient } from '../transport/rpc-client' -import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' - -const noSubscription = (): (() => void) => () => {} - -/** The host status's option values as one stamp; another device's pick moves it, off `enabled` never. */ -export function useMobileStructuredSessionOptionStamp(args: { - client: RpcClient | null - sessionId: string | null - enabled: boolean -}): string { - const { client, sessionId, enabled } = args - const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null - const subscribe = useCallback( - (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), - [feed] - ) - const readStamp = (): string => { - const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined - return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` - } - return useSyncExternalStore(subscribe, readStamp, readStamp) -} diff --git a/mobile/src/session/use-mobile-structured-session-option-status.ts b/mobile/src/session/use-mobile-structured-session-option-status.ts new file mode 100644 index 000000000000..a5211ce922b4 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-option-status.ts @@ -0,0 +1,43 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react' +import { structuralValuesEqual } from '../../../src/shared/structural-value-equality' +import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +type OptionStatus = { + stamp: string + selection: AgentSessionStatusSummary['optionSelection'] +} + +const EMPTY_OPTION_STATUS: OptionStatus = { stamp: '', selection: undefined } + +/** A live selection from a capable host, with the legacy stamp for older hosts. */ +export function useMobileStructuredSessionOptionStatus(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): OptionStatus { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStatus = useMemo(() => { + let previous = EMPTY_OPTION_STATUS + return (): OptionStatus => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + const stamp = `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + const selection = structuralValuesEqual(previous.selection, summary?.optionSelection) + ? previous.selection + : summary?.optionSelection + if (previous.stamp !== stamp || previous.selection !== selection) { + previous = { stamp, selection } + } + return previous + } + }, [feed, sessionId]) + return useSyncExternalStore(subscribe, readStatus, () => EMPTY_OPTION_STATUS) +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 79f761f5511e..8f7fa99947ff 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -266,6 +266,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { questionAnswers: false, queuedMessages: false, queuedCommands: false, + messageChoices: false, quietRepeatedStop: false, statusFeed }, diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts new file mode 100644 index 000000000000..9fb2d6540a20 --- /dev/null +++ b/src/main/acp/acp-message-execution-options.ts @@ -0,0 +1,52 @@ +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { AcpAgentError } from './acp-errors' +import type { AcpStructuredSession } from './acp-structured-session' +import { acpPromptErrorDetail } from './acp-turn-failures' + +export async function applyAcpMessageOptions( + session: AcpStructuredSession, + options: AgentSessionExecutionOptions | undefined, + setOption: (key: 'model' | 'effort', value: string) => Promise, + isCurrent: () => boolean +): Promise { + if (!options) { + return true + } + const initial = session.options.messageDefault() + const model = options?.model ?? initial.model + const defaultEffort = + model === initial.model + ? initial.effort + : session.options.read().models.find((entry) => entry.id === model)?.defaultEffort + for (const key of ['model', 'effort'] as const) { + const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort) + if (value === undefined) { + if (session.options.reported()[key] !== undefined) { + throw new AgentSessionOptionRejectedError( + new Error(`The default ${key} for this model is unknown`) + ) + } + continue + } + if (session.options.reported()[key] === value) { + continue + } + try { + await setOption(key, value) + } catch (error) { + if (error instanceof AcpAgentError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(acpPromptErrorDetail(session.spec.dialect, error), 'person') + ) + } + throw error + } + if (!isCurrent()) { + return false + } + } + return true +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 071458ba0a89..d5565f9fdfd7 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -235,7 +235,6 @@ export async function acquireAcpStructuredSession(input: { options, prompts, childStops: undefined, - restoreSkipped: [], closeRequested: false, journalClosed: null, ended: false, diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index 4602b1738f9b..78b3493e2d2f 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -21,7 +21,6 @@ export type AcpStructuredChild = Pick< options: AcpStructuredSession['options'] prompts: AcpStructuredSession['prompts'] childStops?: AcpStructuredSession['childStops'] - restoreSkipped: readonly string[] unbindReadingControl?: () => void open: (signal: AbortSignal) => Promise closing?: Promise diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 1d61e51a8240..71dcfd0adb32 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -17,7 +17,7 @@ import { probeAcpChildStops } from './acp-structured-child-stop' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -import { restoreAcpSessionOptions, type AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredOptions } from './acp-structured-options' import type { AcpStructuredPrompts } from './acp-structured-prompts' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' @@ -102,8 +102,6 @@ export async function initializeAcpStructuredSession( assertStarting() options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) - const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) - assertStarting() const link: AgentSessionProviderHandleLink = { linkId: deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), @@ -146,8 +144,7 @@ export async function initializeAcpStructuredSession( agentName, now, settle: onSettled - }), - restoreSkipped + }) }) if (!isAcpReadySession(child)) { throw new Error('ACP session did not open') @@ -155,7 +152,6 @@ export async function initializeAcpStructuredSession( return { link, reportedOptions: options.read().current, - // Its saved picks restored: what it runs now is what its start resolved. catalogListing: options.startListing() } } diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 86db37f29252..547cf84225b8 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -9,8 +9,9 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' -import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import { framesOf, openAttachedHostRig, send, stop } from './acp-structured-host.test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STOP_NOTE_CANCELLATION_REQUESTED } from '../native-chat/agent-session-wire/structured-agent-session-turn-stop-notes' afterEach(async () => { await closeProviderTimelineRigs() @@ -40,54 +41,69 @@ async function within(promise: Promise, ms: number) { } } -const failed = (outcome: Awaited>) => - outcome !== 'pending' && - (outcome.settled === 'rejected' || - (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value - ? outcome.value.ok === false - : false)) - -describe('a Grok model pick Grok never answers', () => { - it('does not hold a Stop behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig({ +describe('a Grok model pick Grok never answers during send', () => { + it('does not hold Stop, writes one request note, and ignores a late setter reply', async () => { + const { rig, host, rows } = await openAttachedHostRig({ script: (agent) => agent.on('session/set_config_option', () => {}) }) - const picking = pick(host, 'grok-4.6') + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) + const invalid = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' }) + }) + expect(await within(invalid, 100)).toBe('pending') + const request = { envelope: envelope('agentSession.cancel', {}) } + expect(await within(host.cancel(CALLER, request), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(await invalid).toMatchObject({ ok: false }) + expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true }) + expect( + host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState + ).toBe('rejected') + expect( + (await rows()).filter( + (row) => row.body.kind === 'status' && row.body.text === STOP_NOTE_CANCELLATION_REQUESTED + ) + ).toHaveLength(1) + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') }) - it('does not hold a Close behind it; the pick fails', async () => { + it('does not hold a Close behind its write', async () => { const { rig, host } = await openAttachedHostRig({ script: (agent) => agent.on('session/set_config_option', () => {}) }) - const picking = pick(host, 'grok-4.6') + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) }) - it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + it('bounds its write, frees the chat, and keeps what Grok answers late', async () => { const { rig, host, store, fence } = await openAttachedHostRig({ - optionWriteTimeoutMs: 100 + optionWriteTimeoutMs: 100, + script: (agent) => agent.on('session/set_config_option', () => {}) }) expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) const savedModel = store.getRecord(SESSION)?.options?.model expect(savedModel).toBe('grok-4.6') - const priorWrites = framesOf(rig.child(), 'session/set_config_option').length - rig.child().agent.on('session/set_config_option', () => {}) - const picking = pick(host, 'grok-4.7') + await send(host, 'hello') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) await waitFor(() => - expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(priorWrites + 1) + expect( + host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState + ).toBe('unknown') ) - expect(failed(await within(picking, 2_000))).toBe(true) // The chat's queue moved on: a later operation runs. expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') - const frame = framesOf(rig.child(), 'session/set_config_option').at(-1) + const [frame] = framesOf(rig.child(), 'session/set_config_option') rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => option.id === 'model' ? { ...option, currentValue: 'grok-4.7' } : option @@ -103,3 +119,23 @@ describe('a Grok model pick Grok never answers', () => { await host.close(SESSION, 'user-close') }) }) + +describe('a saved Grok pick', () => { + it.each(['Stop', 'Close'] as const)( + 'never contacts Grok or holds %s behind a setter', + async (ending) => { + const { rig, host, store } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) + expect(await within(pick(host, 'grok-4.6'), 1_000)).toMatchObject({ + settled: 'resolved', + value: { ok: true } + }) + expect(store.getRecord(SESSION)?.options?.model).toBe('grok-4.6') + expect(framesOf(rig.child(), 'session/set_config_option')).toEqual([]) + const ended = ending === 'Stop' ? stop(host) : host.close(SESSION, 'user-close') + expect(await within(ended, 2_000)).toMatchObject({ settled: 'resolved' }) + await host.close(SESSION, 'user-close') + } + ) +}) diff --git a/src/main/acp/acp-structured-missing-model.test.ts b/src/main/acp/acp-structured-missing-model.test.ts index c8df21f6ce6e..0aa9dca3876b 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -47,8 +47,7 @@ function writtenOptions(provider: string, reader: AcpStructuredOptions) { } // What the host writes from the report a started child sends. const options = nativeSessionOptionsFromReport({ - reported: reader.read().current, - restoreSkipped: [] + reported: reader.read().current }) const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index b348d0fdba92..917c9bf690c4 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredOptions } from './acp-structured-options' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' const configOptions = [ @@ -121,11 +121,6 @@ describe('ACP session options', () => { }) describe('what a new session with no pick says about the configured default', () => { - const connection = { - setConfigOption: async () => ({ configOptions }), - setModel: async () => ({}) - } - it('is the model and effort the agent chose for itself', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') @@ -138,17 +133,17 @@ describe('what a new session with no pick says about the configured default', () expect(options.configuredDefault()).toBeUndefined() }) - it('says nothing once the chat holds a model pick, even one the session already runs', async () => { + it('says nothing once the chat holds a model pick, even one the session already runs', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') - await restoreAcpSessionOptions(connection, options, { model: 'model-b' }) + options.notePick('model') expect(options.configuredDefault()).toBeUndefined() }) - it('keeps the model but not an effort the chat picked', async () => { + it('keeps the model but not an effort the chat picked', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') - await restoreAcpSessionOptions(connection, options, { effort: 'low' }) + options.notePick('effort') expect(options.configuredDefault()).toEqual({ modelId: 'model-b' }) }) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index ad17dd78cf4d..d4441044aaaa 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -2,14 +2,14 @@ // effort), the older model list, and its `/` commands — as Orca's option and command surfaces. // Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. +import { abortSignalReason } from '../../shared/abort-signal-reason' + import type { AgentSessionModelOption, AgentSessionOptionChoice, AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' -import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' -import { AcpRpcError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpLaunchSpec } from './acp-launch-specs' import type { AcpDialect } from './acp-dialects/acp-dialect' @@ -64,6 +64,7 @@ export class AcpStructuredOptions { // A loaded session keeps the model it ran; only a new one resolves the agent's own config. private resolvesConfig = false private readonly picked = new Set() + private initialChoice: Readonly> = {} /** The session state a new, load or resume response reported. */ adoptSession( @@ -76,6 +77,12 @@ export class AcpStructuredOptions { this.configOptions = response.configOptions ?? [] this.models = response.models ?? null this.resolvesConfig = origin === 'new' + this.initialChoice = this.reported() + } + + /** The native selection before any message changed this child. */ + messageDefault(): Readonly> { + return this.initialChoice } /** Orca sent `key` to the session, or the chat's saved options hold a value for it. */ @@ -238,7 +245,7 @@ export function writeAcpSessionOption( ): Promise { const { agent, timeoutMs, signal } = bound if (signal?.aborted) { - return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + return Promise.reject(abortSignalReason(signal)) } const applied = write.method === 'config' @@ -251,7 +258,11 @@ export function writeAcpSessionOption( settle() reject(error) } - const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const onAbort = (): void => { + if (signal) { + fail(abortSignalReason(signal)) + } + } const timer = setTimeout( () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), timeoutMs @@ -267,43 +278,3 @@ export function writeAcpSessionOption( }, fail) }) } - -/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and - * reported, never retried. */ -export async function restoreAcpSessionOptions( - connection: Pick, - options: AcpStructuredOptions, - saved: Readonly> | undefined -): Promise { - const skipped: string[] = [] - // Model before effort: an effort menu belongs to the model it is set on. - for (const key of ['model', 'effort']) { - const value = saved?.[key] - if (value === undefined || !isAcpStructuredOptionKey(key)) { - continue - } - options.notePick(key) - if (options.reported()[key] === value) { - continue - } - const write = options.write(key, value) - try { - if (write?.method === 'config') { - options.adoptConfigOptions( - (await connection.setConfigOption(write.configId, value)).configOptions - ) - } else if (write?.method === 'model') { - await connection.setModel(write.modelId) - options.adoptModel(write.modelId) - } else { - skipped.push(key) - } - } catch (error) { - if (!(error instanceof AcpRpcError)) { - throw error - } - skipped.push(key) - } - } - return skipped -} diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index 957c2b356584..33e04d733eaa 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -16,7 +16,7 @@ afterEach(closeProviderTimelineRigs) const configParams = z.object({ configId: z.string(), value: z.string() }) describe('ACP child published before its handshake', () => { - it('returns at spawn, then reports its real session and restored picks in model-before-effort order', async () => { + it('opens without writing saved message choices to the provider', async () => { let answer: (() => void) | undefined const writes: string[] = [] let configOptions = GROK_CONFIG_OPTIONS @@ -58,9 +58,9 @@ describe('ACP child published before its handshake', () => { answer?.() expect(await opening).toMatchObject({ link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, - reportedOptions: { model: 'grok-4.6', effort: 'low' } + reportedOptions: { model: 'grok-4.7', effort: 'high' } }) - expect(writes).toEqual(['model', 'reasoning_effort']) + expect(writes).toEqual([]) rig.child().agent.notify('session/update', { sessionId: PROVIDER_SESSION, update: { sessionUpdate: 'config_option_update', configOptions: GROK_CONFIG_OPTIONS } @@ -155,7 +155,7 @@ describe('ACP child published before its handshake', () => { expect(rig.child().exited).toBe(true) }) - it('keeps refused picks in provider-local facts without delaying readiness for another read', async () => { + it('waits until message input to try a saved choice the provider will refuse', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/set_config_option', (frame) => @@ -166,7 +166,18 @@ describe('ACP child published before its handshake', () => { expect(rig.started()).toMatchObject({ reportedOptions: { model: 'grok-4.7', effort: 'high' } }) - expect(rig.adapter.readOptionRestoreFailures(SESSION)).toEqual(['model']) + expect(rig.sent('session/set_config_option')).toEqual([]) + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'refused-choice', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'retired-model' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/set_config_option')).toHaveLength(1) + expect(rig.sent('session/prompt')).toEqual([]) expect(rig.child().exited).toBe(false) }) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 716c6e65f5d2..b470ab562c36 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -158,6 +158,28 @@ it('reads those last words when the agent ends its stdout before its exit is see }) describe('ACP structured session adapter: turns', () => { + it('applies a sent message’s model and effort before its prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'chosen-send', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.frame('session/prompt') + const methods = rig.child().agent.frames.map((frame) => frame.method) + expect(methods.slice(-3)).toEqual([ + 'session/set_config_option', + 'session/set_config_option', + 'session/prompt' + ]) + await expect(rig.adapter.readOptions({ sessionId: SESSION, fence: 1 })).resolves.toMatchObject({ + current: { model: 'grok-4.6', effort: 'low' } + }) + }) + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { const rig = await openAcpAdapterRig({ deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 3efd9f928508..72bf7ca2be2e 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto' import { agentSessionFailureFact, withProviderDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { AgentSessionAcquisitionExitProvenError, @@ -27,6 +26,8 @@ import type { AcpStructuredSession } from './acp-structured-session' import type { ProviderStartAttempt } from '../provider-process/provider-acquisition-starts' import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' +import { applyAcpMessageOptions } from './acp-message-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { @@ -162,14 +163,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - fence: number - requestedAt?: number - beforeDispatch?: () => Promise - }): Promise { + async dispatch( + input: Parameters[0] + ): Promise { const lost = this.children.sessions.get(input.sessionId) if (lost && lost.journalClosed !== null) { // The connection broke and the exit is not yet proven: the message never left Orca. @@ -184,7 +180,20 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte // The child ended while its attachments were read: nothing left Orca. return this.rejected(session, 'providerExited') } + const { sessionId, fence, signal } = input + const optionsApplied = await applyAcpMessageOptions( + session, + input.executionOptions, + (key, value) => this.setOption({ sessionId, fence, key, value, signal }), + () => + this.children.sessions.get(input.sessionId) === session && session.journalClosed === null + ) + if (!optionsApplied) { + return this.rejected(session, 'providerExited') + } + input.signal?.throwIfAborted() await input.beforeDispatch?.() + input.signal?.throwIfAborted() session.turns.dispatch({ clientMessageId: input.clientMessageId, prompt, @@ -235,7 +244,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const session = this.live(input.sessionId) const write = session.options.write(input.key, input.value) if (!write) { - throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + throw new AgentSessionOptionRejectedError( + `${session.spec.agent} offers no session option named ${input.key}` + ) } session.options.notePick(input.key) // Bounded, and abandoned by a close or Stop: the session's queue waits on it. @@ -250,11 +261,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => { - const session = this.children.sessions.get(sessionId) - return session && isAcpReadySession(session) ? session.restoreSkipped : [] - } - readCommands = (sessionId: string) => { const session = this.children.sessions.get(sessionId) return session && isAcpReadySession(session) ? session.options.readCommands() : undefined diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index c78b9b7581fd..b0cde0e1a2ae 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -29,7 +29,6 @@ export type AcpStructuredSession = { prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns - restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean /** Why nothing this child says reaches the journal any more; null while it does. */ diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 6ad422fa1606..43fdcdee9c4c 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -1,3 +1,4 @@ +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -98,7 +99,14 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, agents, - store: { getRecord: () => undefined } + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + sessionId: SESSION, + provider: 'claude', + options: { model: 'opus' } + }) + } }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => running, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 809a81d70263..a0efb5eddbaa 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -106,7 +106,6 @@ export async function resolveClaudeAcquisitionLaunch(args: { options: spawn.sdkOptions, savedOptions: { options: spawn.options, - skipped: spawn.skipped, fastModeAtStart: spawn.fastModeAtStart } } diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 4b9718ae45fe..1ba74cca6b62 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,7 +29,6 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 3467a474e0bf..3604e1e86cf1 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -20,6 +20,7 @@ import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session- import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { agentSessionFailureFact, + providerDiagnostic, type SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' @@ -28,6 +29,8 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' +import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { ClaudeControlRequestError } from './claude-stream-json-connection' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -171,6 +174,19 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...claudeDispatchRejection( + agentSessionFailureFact( + 'providerRejected', + error.cause instanceof ClaudeControlRequestError + ? { detail: providerDiagnostic(error.message, 'person') } + : {} + ) + ) + } + } return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter 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 7cc0a917b4aa..72d91fc93653 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 @@ -402,9 +402,7 @@ describe('Claude effort default at rest', () => { const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') // The record the start persists names the listed row the catalog learned under. - const record = restingRecord( - nativeSessionOptionsFromReport({ reported: reported, restoreSkipped: [] }) - ) + const record = restingRecord(nativeSessionOptionsFromReport({ reported: reported })) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') expect(record.options).toEqual({ model: 'opus[1m]' }) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 3305a40d77a3..c5adc15c2d6f 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -26,7 +26,6 @@ function sessionWith( optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts new file mode 100644 index 000000000000..2cc53747affb --- /dev/null +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { + acquired, + claudeFrame, + fakeClaude, + USER_MESSAGE +} from './claude-structured-session-test-support' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const MODELS = [ + { value: 'default', resolvedModel: 'claude-sonnet-5' }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } +] + +describe('Claude accepted message choices', () => { + it('applies a held message A before its prompt, then message B on the existing child', async () => { + const order: string[] = [] + let currentEffort = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + set_model: ({ model } = {}) => order.push(`model:${String(model)}`), + apply_flag_settings: ({ settings } = {}) => { + order.push(`effort:${JSON.stringify(settings)}`) + if ( + typeof settings === 'object' && + settings !== null && + 'effortLevel' in settings && + typeof settings.effortLevel === 'string' + ) { + currentEffort = settings.effortLevel + } + } + } + }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + connection.getSettings = async () => ({ effective: { effortLevel: currentEffort } }) + const send = connection.send + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let entered!: () => void + const enteredSend = new Promise((resolve) => { + entered = resolve + }) + let first = true + connection.send = async (message, beforeDispatch) => { + if (first) { + first = false + entered() + await held + } + const result = await send(message, beforeDispatch) + order.push('prompt') + return result + } + + const a = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'A', + body: USER_MESSAGE, + executionOptions: { model: 'sonnet', effort: 'low' }, + fence: 7 + }) + await enteredSend + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + order.length = 0 + release() + await expect(a).resolves.toEqual({ state: 'admitted' }) + expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt']) + + order.length = 0 + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'B', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'high' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) + }) + + it('restores the configured model and effort for an empty snapshot', async () => { + let currentEffort = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + apply_flag_settings: ({ settings } = {}) => { + if ( + typeof settings === 'object' && + settings !== null && + 'effortLevel' in settings && + typeof settings.effortLevel === 'string' + ) { + currentEffort = settings.effortLevel + } + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + await vi.waitFor(() => + expect(events).toContainEqual(expect.objectContaining({ type: 'options-reported' })) + ) + claude.connections[0]!.getSettings = async () => ({ effective: { effortLevel: currentEffort } }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'A', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'low' }, + fence: 7 + }) + const connection = claude.connections[0]! + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' }) + connection.calls.length = 0 + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'default', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect( + connection.calls.filter((call) => ['set_model', 'apply_flag_settings'].includes(call.subtype)) + ).toEqual([ + { subtype: 'set_model', params: { model: 'claude-sonnet-5' } }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } } + ]) + expect(connection.sent).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts new file mode 100644 index 000000000000..8f96f77e8753 --- /dev/null +++ b/src/main/claude/claude-structured-message-options.ts @@ -0,0 +1,116 @@ +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { listedModels } from './claude-structured-model-catalog' +import { setClaudeStructuredOption } from './claude-structured-options' +import { readClaudeCurrentModel } from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' + +/** Applies the accepted message's choices while its prompt is still in the SDK input queue. */ +export async function applyClaudeMessageOptions( + session: ClaudeSession, + options: AgentSessionExecutionOptions, + timeoutMs: number | undefined, + signal?: AbortSignal +): Promise { + throwIfSignalAborted(signal) + const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal)) + if (model && readClaudeCurrentModel(session).id !== model) { + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs), + signal + ) + } + + throwIfSignalAborted(signal) + const effort = options.effort ?? session.configuredDefaults?.effort + if ( + effort && + (session.options.get('effort') !== effort || !session.confirmedOptions.has('effort')) + ) { + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs), + signal + ) + if ( + effort !== 'max' && + !session.confirmedOptions.has('effort') && + session.reportedOptions.effort !== undefined && + session.reportedOptions.effort !== effort + ) { + throw new AgentSessionOptionRejectedError( + `Claude reported effort ${session.reportedOptions.effort} instead of ${effort}` + ) + } + } else if (!effort && session.options.has('effort')) { + throw new AgentSessionOptionRejectedError('Claude did not report its configured effort') + } + + throwIfSignalAborted(signal) + const fastMode = options.fastMode ?? 'false' + if ( + (session.options.get('fastMode') !== fastMode || + (session.fastModeAtStart && !session.confirmedOptions.has('fastMode'))) && + (options.fastMode || session.options.has('fastMode')) + ) { + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs), + signal + ) + if (session.options.get('fastMode') !== fastMode) { + throw new AgentSessionOptionRejectedError(`Claude did not apply Fast mode ${fastMode}`) + } + } + throwIfSignalAborted(signal) +} + +async function defaultModelForMessage( + session: ClaudeSession, + timeoutMs: number | undefined, + signal?: AbortSignal +): Promise { + if (session.configuredDefaults?.model) { + return session.configuredDefaults.model + } + if (!session.options.has('model')) { + return undefined + } + const response = await waitForPromiseWithSignal( + session.connection.supportedModels({ timeoutMs }), + signal + ).catch(() => null) + throwIfSignalAborted(signal) + const listed = listedModels(response ? { models: response } : null) + const fallback = listed.find((model) => model.isDefault)?.id + if (!fallback) { + throw new AgentSessionOptionRejectedError('Claude did not report its default model') + } + return fallback +} + +export function dispatchClaudeMessage( + session: ClaudeSession, + input: Parameters[0], + timeoutMs: number | undefined +): ReturnType { + const messageOptions = input.executionOptions + return dispatchClaudeTurn( + session, + input, + messageOptions === undefined + ? input.beforeDispatch + : async () => { + await input.beforeDispatch?.() + try { + await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) + input.signal?.throwIfAborted() + } catch (error) { + throw error instanceof AgentSessionOptionRejectedError + ? error + : new AgentSessionOptionRejectedError(error) + } + } + ) +} diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index a44ca0232b87..c02a9e528c64 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -22,7 +22,6 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 9416bf947a50..eab0aef168d9 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -36,7 +36,6 @@ describe('the context ring after a session option write', () => { const s = ringSession() adoptClaudeStructuredSpawnOptions(s.session, { options: new Map([['model', 'opus[1m]']]), - skipped: [], fastModeAtStart: false }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 64ada44671d7..2f4f84e48c8d 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -38,7 +38,6 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index bc8e49065b65..d846553af1de 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -41,8 +41,7 @@ function modelNotFound(parentToolUseId: string | null = null): Record() + options: new Map(picked === null ? [] : [['model', picked]]) } } @@ -52,7 +51,6 @@ describe("the CLI's word that a launched model does not exist", () => { expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED) expect(session.options.has('model')).toBe(false) - expect([...session.restoreSkippedOptions]).toEqual(['model']) expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull() }) @@ -63,7 +61,6 @@ describe("the CLI's word that a launched model does not exist", () => { ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] ])('keeps the saved model for %s', (_case, session, message) => { expect(retireClaudeLaunchedModel(session, message)).toBeNull() - expect([...session.restoreSkippedOptions]).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 3b233201722b..ce574cb680fe 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -8,9 +8,9 @@ import type { } from './claude-structured-session-state' /** The launched model, once, when a root reply says it does not exist and the chat still has it - * picked: the session stops holding it, so the record drops it. Null otherwise. */ + * picked: the live session stops holding it. Saved picks remain the user's. */ export function retireClaudeLaunchedModel( - session: Pick, + session: Pick, message: Record ): string | null { const launched = session.launchedModel @@ -24,7 +24,6 @@ export function retireClaudeLaunchedModel( return null } session.options.delete('model') - session.restoreSkippedOptions.add('model') return launched } diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 413d2db4fb67..0fa3c8f287eb 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -12,6 +12,25 @@ import { } from './claude-structured-session-test-support' describe('ClaudeStructuredSessionAdapter turns and controls', () => { + it('applies a sent model and effort before handing Claude the message', async () => { + const claude = fakeClaude({ replayUuid: 'chosen-uuid' }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + const before = connection.calls.length + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'chosen-send', + body: USER_MESSAGE, + fence: 7, + executionOptions: { model: 'sonnet', effort: 'high' } + }) + ).resolves.toEqual({ state: 'admitted' }) + const controls = connection.calls.slice(before).map((call) => call.subtype) + expect(controls.indexOf('set_model')).toBeGreaterThan(-1) + expect(controls.indexOf('apply_flag_settings')).toBeGreaterThan(controls.indexOf('set_model')) + }) + it("admits a dispatch on the write and names it from Claude's replay", async () => { const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) const settled = vi.fn() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 87c04b453ec7..6b93a2b99431 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -7,10 +7,6 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError -} from './claude-agent-sdk-control-requests' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -129,89 +125,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - // Left out, not refused: the record takes the provider's own Fast, as before. - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ - reportedOptions: { fastMode: false } - }) - }) - - // No message waits on it: the launch carries no Fast, and the start applies it once the CLI's - // settings show no per-session opt-in, as the live CLI reports with no settings file at all. - it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => { - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] - }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - expect(claude.connections[0].launch.options.settings).toBeUndefined() - expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ - 'initialize', - 'get_settings', - 'apply_flag_settings' - ]) - expect(claude.connections[0].calls.at(-1)).toEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) - expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ reportedOptions: { fastMode: true } }) }) - it.each([ - ['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false], - ['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true] - ] as const)( - 'settles a saved Fast on a new conversation when the CLI %s its apply', - async (_case, failure, kept) => { - let answerApply: (error: Error) => void = () => {} - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], - routes: { - apply_flag_settings: () => new Promise((_, reject) => (answerApply = reject)) - } - }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - // Started, with the pick, while the apply is still unanswered: nothing waits on it. - expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') - expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ - reportedOptions: { fastMode: true } - }) - answerApply(failure) - await tick() - - // A refusal drops the child's value; silence keeps it wanted and unconfirmed. - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) - const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current.fastMode === true).toBe(kept) - expect(options.current.confirmed ?? []).not.toContain('fastMode') - } - ) - - it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { + it('leaves Fast for message input when the launch does not carry it', async () => { const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] + initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] }) const adapter = adapterFor(claude) @@ -221,15 +143,16 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9', options: { model: 'opus', fastMode: 'true' } }) + await claudeOpened(adapter, 'session-1') + expect(claude.connections[0].launch.options.settings).toBeUndefined() expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) }) // The chat's permission mode is the start's, never a saved option the CLI is handed. - it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => { + it('omits an invalid persisted effort at launch and uses the explicit start mode', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch') @@ -237,7 +160,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options } await adapter.acquire({ ...start, permissionMode: 'accept-edits' }) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort']) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits') }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d4c9689d863d..683f69d67de0 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,12 +7,12 @@ import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopCurrentClaudeBackgroundTasks } from './claude-structured-control-actions' -import { dispatchClaudeTurn } from './claude-structured-dispatch' import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' +import { dispatchClaudeMessage } from './claude-structured-message-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -164,7 +164,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) + dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs) compact: NonNullable = (input) => dispatchClaudeCommand(this.session(input.sessionId), input.command) @@ -256,9 +256,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ - ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) - ] releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 5630e0f95986..77e0cb83a36a 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -63,7 +63,6 @@ export function createClaudeSessionPublication(input: { reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), launchedModel: null, fastModeAtStart: false, translator: input.translator, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 42cc302773cd..a36d490ecc4a 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -3,7 +3,8 @@ import { adapterAtPublishFor, fakeClaude, identityFor, - claudeOpened + claudeOpened, + USER_MESSAGE } from './claude-structured-session-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' @@ -60,6 +61,47 @@ describe('Claude host-awaited open', () => { await adapter.closeAll() }) + it('applies a Fast choice at message input when launch settings cannot carry it', async () => { + let fastMode = false + const claude = fakeClaude({ + initModels: [{ value: 'claude-sonnet-5', displayName: 'Sonnet', supportsFastMode: true }], + routes: { + apply_flag_settings: () => { + fastMode = true + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterAtPublishFor( + claude, + { options: { extraArgs: { settings: '/repo/claude.json' } } }, + events + ) + await adapter.acquire({ ...ACQUIRE, options: { fastMode: 'true' } }) + await claudeOpened(adapter, 'session-1') + await vi.waitFor(() => + expect(events.some((event) => event.type === 'options-reported')).toBe(true) + ) + const connection = claude.connections[0]! + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([]) + connection.getSettings = async () => ({ effective: { fastMode } }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'fast-choice', + body: USER_MESSAGE, + executionOptions: { fastMode: 'true' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([ + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, + { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } } + ]) + expect(connection.sent).toHaveLength(1) + await adapter.closeAll() + }) + it('rejects an advertised signed-out initialize answer for the host to close once', async () => { const claude = fakeClaude({ initAccount: { tokenSource: 'none', apiKeySource: 'none' } }) const { adapter } = adapterFor(claude) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index f924756e6221..b4c9c3924d08 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -21,6 +21,7 @@ import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, + readClaudeSettingsApplied, readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' @@ -29,10 +30,6 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - admitClaudeStartFastMode, - applyClaudeStartFastMode -} from './claude-structured-start-fast-mode' export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, @@ -132,6 +129,13 @@ function applyClaudeSettingsFacts( const { settings, prepared } = facts const effort = readClaudeSettingsEffort(settings) const published = claudeStructuredSessionPublicationOptions(prepared) + if (!writtenSinceRead) { + const applied = readClaudeSettingsApplied(settings) + session.configuredDefaults = { + ...(!session.options.has('model') && applied?.model ? { model: applied.model } : {}), + ...(!session.options.has('effort') && applied?.effort ? { effort: applied.effort } : {}) + } + } observeClaudeSettingsApplied(session, settings) if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn @@ -245,7 +249,6 @@ async function settleClaudeStartupSettings( input.diagnose(claudeAuthDiagnostic(facts.initialization, facts.init, settings)) const writtenSinceRead = sequence !== session.optionMutationSequence applyClaudeSettingsFacts(session, facts, writtenSinceRead) - const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', // Open already returned the initialize listing to the host. @@ -254,7 +257,4 @@ async function settleClaudeStartupSettings( // The one report of what the config resolved: the readback just applied it. ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) - if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode) - } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index c454ad63e67a..2068b5d64fe1 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -158,6 +158,8 @@ export type ClaudeSession = { /** What `get_settings` says the next request will send, after Claude's own env and settings * precedence: the lowest-ranked answer, unconfirmed until a turn reports it. */ appliedOptions?: { model?: string; effort?: string } + /** CLI settings resolved before a turn changed them, when no launch override hid that value. */ + configuredDefaults?: { model?: string; effort?: string } fastModeState?: AgentSessionFastModeState fastModeDisabledReason?: string fastModePerSessionOptIn?: boolean @@ -166,7 +168,6 @@ export type ClaudeSession = { reportedModelMutation: number /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set - restoreSkippedOptions: Set /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null /** The launch left the saved Fast out, so the start applies it once the settings are read. */ diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index d155d423c957..49731ee5168c 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -44,7 +44,6 @@ describe('a Claude chat launched with its saved options', () => { effort: 'high', fastMode: 'true' }) - expect(spawn.skipped).toEqual([]) }) // A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer @@ -55,7 +54,6 @@ describe('a Claude chat launched with its saved options', () => { const spawn = launched({ model, effort: 'xhigh' }) expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' }) - expect(spawn.skipped).toEqual([]) } ) @@ -64,7 +62,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('effort') expect(spawn.sdkOptions).not.toHaveProperty('settings') - expect(spawn.skipped).toEqual(['effort', 'fastMode']) }) it('keeps a saved Fast on for a new conversation, for its start to apply', () => { @@ -73,7 +70,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('settings') expect(spawn.options.get('fastMode')).toBe('true') expect(spawn.fastModeAtStart).toBe(true) - expect(spawn.skipped).toEqual([]) }) it('passes a saved Fast off to a new conversation', () => { @@ -128,7 +124,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH) expect(spawn.options.size).toBe(0) - expect(spawn.skipped).toEqual([]) } ) }) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index b86b4ebfba50..da5417039e72 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -1,6 +1,4 @@ -// A chat's saved options as the Claude child's launch options. The child starts already running -// them, so its first message can be written at once instead of waiting for initialize to answer and -// a control request to apply each one. +// Saved choices seed the CLI launch; message choices are applied again before input. import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -39,8 +37,6 @@ export type ClaudeStructuredSpawnOptions = { sdkOptions: ClaudeStructuredSdkOptions /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ options: Map - /** Saved options left out: the provider's own value wins and is re-persisted. */ - skipped: readonly string[] /** A saved Fast the launch does not carry, which the start applies. */ fastModeAtStart: boolean } @@ -56,7 +52,6 @@ export function claudeStructuredSpawnOptions(input: { }): ClaudeStructuredSpawnOptions { const saved = restoredClaudeStructuredSessionOptions(input.saved) const options = new Map() - const skipped: string[] = [] let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } let fastModeAtStart = false const model = saved.get('model') @@ -69,20 +64,15 @@ export function claudeStructuredSpawnOptions(input: { if (isEffortLevel(effort)) { options.set('effort', effort) sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort } - } else { - skipped.push('effort') } } const fastMode = saved.get('fastMode') if (fastMode !== undefined) { const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) - if (typeof decoded !== 'boolean') { - skipped.push('fastMode') - } else { + if (typeof decoded === 'boolean') { options.set('fastMode', fastMode) - // A new CLI session may opt in to Fast per session, which only its settings say, and the - // agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the - // start applies the saved Fast once it has read them (`applyClaudeStartFastMode`). + // A new CLI session may opt in to Fast per session; the first message applies a saved Fast + // when launch settings cannot carry it. if ( (!decoded || input.launch.resumesTranscript) && !Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings') @@ -93,22 +83,16 @@ export function claudeStructuredSpawnOptions(input: { } } } - return { sdkOptions, options, skipped, fastModeAtStart } + return { sdkOptions, options, fastModeAtStart } } /** The published session takes on what its child was launched with. */ export function adoptClaudeStructuredSpawnOptions( - session: Pick< - ClaudeSession, - 'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart' - >, - spawn: Pick + session: Pick, + spawn: Pick ): void { session.launchedModel = spawn.options.get('model') ?? null session.fastModeAtStart = spawn.fastModeAtStart - for (const key of spawn.skipped) { - session.restoreSkippedOptions.add(key) - } const model = spawn.options.get('model') if (model !== undefined) { session.translator?.modelWritten(model) diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts index 24057e865fca..16f9693f0117 100644 --- a/src/main/claude/claude-structured-start-fast-mode.test.ts +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -1,11 +1,23 @@ // A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the -// start once the child answers. +// message input once the child answers. import { expect, it, vi } from 'vitest' -import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' +import { + adapterFor, + fakeClaude, + identityFor, + claudeOpened, + USER_MESSAGE +} from './claude-structured-session-test-support' -it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => { +it("applies a resumed conversation's saved Fast off before message input beside the Arguments' settings", async () => { + let fastMode = true const claude = fakeClaude({ + routes: { + apply_flag_settings: () => { + fastMode = false + } + }, settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } }) const adapter = adapterFor(claude, { @@ -25,8 +37,23 @@ it("applies a resumed conversation's saved Fast off after start beside the Argum expect(claude.connections[0].launch.options.extraArgs).toEqual({ settings: '/repo/claude.json' }) + await claudeOpened(adapter, 'session-1') + claude.connections[0].getSettings = async () => ({ + effective: { fastMode, fastModePerSessionOptIn: false } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'message-fast', + body: USER_MESSAGE, + executionOptions: { fastMode: 'false' }, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) await vi.waitFor(() => - expect(claude.connections[0].calls.at(-1)).toEqual({ + expect( + claude.connections[0].calls.find((call) => call.subtype === 'apply_flag_settings') + ).toEqual({ subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } }) diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts deleted file mode 100644 index 4650cf897ada..000000000000 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ /dev/null @@ -1,70 +0,0 @@ -// A saved Fast the Claude child was launched without, applied once its start has read the settings -// that decide whether it may run. - -import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' -import { readClaudeModels } from './claude-structured-init-proof' -import { listedModels } from './claude-structured-model-catalog' -import { claudeModelFastModeSupport } from './claude-structured-session-options' -import type { ClaudeStartupFacts } from './claude-structured-session-startup' -import type { ClaudeSession } from './claude-structured-session-state' - -/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings - * may opt in to it per session, or either value beside the agent Arguments' own `--settings`. - * Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a - * live Fast write takes. The value still to be applied, or null. */ -export function admitClaudeStartFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts -): string | null { - const fastMode = session.options.get('fastMode') - if (!session.fastModeAtStart || fastMode === undefined) { - return null - } - if (fastMode !== 'true') { - return fastMode - } - if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) { - session.options.delete('fastMode') - return null - } - // Over the listing this start already holds. - const listed = listedModels({ models: readClaudeModels(facts.initialization) }) - const blocked = - session.fastModeDisabledReason !== undefined && - !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) - if ( - blocked || - (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) - ) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - return null - } - return fastMode -} - -/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal - * drops the child's value; silence keeps it wanted and - * unconfirmed. A write the user made meanwhile owns the option. */ -export async function applyClaudeStartFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts, - fastMode: string -): Promise { - const sequence = session.optionMutationSequence - try { - await session.connection.applyFlagSettings( - { fastMode: fastMode === 'true' }, - { timeoutMs: facts.requestTimeoutMs } - ) - } catch (error) { - if (sequence !== session.optionMutationSequence) { - return - } - session.confirmedOptions.delete('fastMode') - if (error instanceof ClaudeControlRequestError) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - } - } -} diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts new file mode 100644 index 000000000000..6bef482395ea --- /dev/null +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -0,0 +1,222 @@ +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' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' +import { startCodexTurn } from './codex-structured-turn-start' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import type { CodexSession } from './codex-structured-session-state' +import { + acquireReadyCodexForTest, + USER_MESSAGE, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' + +function seedMessageDefaults(session: CodexSession): void { + const store = new AgentModelCatalogStore() + const fingerprint = 'codex-message-defaults' + store.recordSuccess( + fingerprint, + 'codex', + { + origin: 'probe', + models: [ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + defaultEffort: 'low', + serviceTiers: [{ value: 'priority', label: 'Priority' }], + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'model-b', + label: 'Model B', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ] + }, + 'discovery' + ) + session.catalogAccess = { store, fingerprint, accountHomePath: '/test-account' } +} + +describe('Codex captured message choices', () => { + it('uses each message’s choices on turn/start and tracks the native attempted choice', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'medium') + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + for (const [id, model, effort] of [ + ['first', 'model-a', 'high'], + ['second', 'model-b', 'low'] + ]) { + const turnOptions = await resolvedCodexMessageOptions(session, { model, effort }) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + expect( + request.mock.calls + .filter(([method]) => method === 'turn/start') + .map(([, params]) => [params?.model, params?.effort]) + ).toEqual([ + ['model-a', 'high'], + ['model-b', 'low'] + ]) + expect(Object.fromEntries(session.options)).toEqual({ + model: 'model-b', + effort: 'low', + personality: 'none', + serviceTier: 'default' + }) + }) + + it('resets an explicit empty choice after a thread launch and earlier turn override', async () => { + const request = vi.fn(async (method: string, _params?: Record) => + method === 'turn/start' ? { turn: { id: 'opened' } } : {} + ) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', effort: 'high' } + session.options = new Map([ + ['model', 'model-a'], + ['effort', 'high'], + ['personality', 'friendly'], + ['serviceTier', 'priority'] + ]) + seedMessageDefaults(session) + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + + for (const [id, choices] of [ + [ + 'first', + { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' } + ], + ['second', {}], + ['third', { model: 'model-a' }] + ] as const) { + const turnOptions = await resolvedCodexMessageOptions(session, choices) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + + expect( + request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params) + ).toMatchObject([ + { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' }, + { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' }, + { model: 'model-a', effort: 'low', personality: 'none', serviceTier: 'default' } + ]) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'turn/start', + 'turn/start', + 'turn/start' + ]) + expect(Object.fromEntries(session.options)).toEqual({ + model: 'model-a', + effort: 'low', + personality: 'none', + serviceTier: 'default' + }) + }) + + it('opens from the first captured model even when launch resolution saw a newer saved pick', async () => { + const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'opened' } }) }) + const adapter = adapterFor(codex, { model: 'model-b' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: { model: 'model-a', effort: 'high' } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + body: USER_MESSAGE, + executionOptions: { model: 'model-a', effort: 'high' }, + fence: 7 + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).toMatchObject({ model: 'model-a' }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params + ).toMatchObject({ model: 'model-a', effort: 'high' }) + }) + + it('preserves the old host path when no message snapshot was supplied', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'legacy' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'high') + const turnOptions = await resolvedCodexMessageOptions(session, undefined) + await startCodexTurn(session, { + clientMessageId: 'legacy', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) + }) + + it('refuses a default reset with no trustworthy default before prompt input', async () => { + const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) + const session = optionSession(request) + session.options.set('model', 'old-override') + session.options.set('effort', 'high') + await expect(resolvedCodexMessageOptions(session, {})).rejects.toThrow( + 'default model is unavailable' + ) + expect(request).not.toHaveBeenCalled() + }) + + it('restores native options after a known refusal and retains them after an unknown answer', async () => { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + const known = optionSession( + vi.fn(async () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'invalid model') + }) + ) + known.options.set('model', 'prior') + await expect( + startCodexTurn(known, { clientMessageId: 'known', body, turnOptions: { model: 'attempted' } }) + ).rejects.toThrow('invalid model') + expect(Object.fromEntries(known.options)).toEqual({ model: 'prior' }) + const unknown = optionSession( + vi.fn(async () => { + throw new Error('connection lost after write') + }) + ) + unknown.options.set('model', 'prior') + await expect( + startCodexTurn(unknown, { + clientMessageId: 'unknown', + body, + turnOptions: { model: 'attempted' } + }) + ).rejects.toThrow('connection lost after write') + expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' }) + }) +}) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts new file mode 100644 index 000000000000..57b870e14040 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.ts @@ -0,0 +1,68 @@ +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' +import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier' +import type { CodexSession } from './codex-structured-session-state' +import { applyCodexStructuredSessionOption } from './codex-structured-session-options' + +/** Validate one message against known facts without reading the provider or changing the next pick. */ +export async function resolvedCodexMessageOptions( + session: CodexSession, + choices: AgentSessionExecutionOptions | undefined +): Promise> | undefined> { + if (choices === undefined) { + return undefined + } + const listing = codexAcquireCatalogListing(session.catalogAccess) + const listedDefault = listing?.models.find((entry) => entry.isDefault) + const priorModel = session.options.get('model') + const priorEffort = session.options.get('effort') + const model = + choices.model ?? + listedDefault?.id ?? + session.nativeDefaults?.model ?? + (priorModel || priorEffort ? undefined : session.reportedOptions.model) + if (!model && (choices.effort || priorModel || priorEffort)) { + throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable')) + } + const listedModel = listing?.models.find((entry) => entry.id === model) + const defaultEffort = + listedModel?.defaultEffort ?? + (model === session.nativeDefaults?.model ? session.nativeDefaults?.effort : undefined) + if (choices.effort === undefined && priorEffort && !defaultEffort) { + throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable')) + } + // Codex retains turn overrides, so defaults must be named after an earlier override. + const selected = { + ...session, + options: new Map([ + ['personality', 'none'], + ['serviceTier', CODEX_DEFAULT_SERVICE_TIER] + ]), + reportedOptions: { ...session.reportedOptions, effort: undefined } + } + if ( + model && + (choices.model || + priorModel || + priorEffort || + choices.effort || + choices.fastMode === 'true' || + (choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER)) + ) { + await applyCodexStructuredSessionOption(selected, 'model', model) + if (!defaultEffort && choices.effort === undefined) { + selected.options.delete('effort') + } + } + if (choices.effort === undefined && defaultEffort && (priorEffort || choices.model)) { + await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + } + for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { + const value = choices[key] + if (value !== undefined) { + await applyCodexStructuredSessionOption(selected, key, value) + } + } + return Object.fromEntries(selected.options) +} diff --git a/src/main/codex/codex-structured-option-session.test-fixture.ts b/src/main/codex/codex-structured-option-session.test-fixture.ts new file mode 100644 index 000000000000..7f1146a0d4f4 --- /dev/null +++ b/src/main/codex/codex-structured-option-session.test-fixture.ts @@ -0,0 +1,31 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import type { CodexSession } from './codex-structured-session-state' + +export function optionSession(request: CodexAppServerConnection['request']): CodexSession { + return { + connection: { + pid: 1, + closed: false, + request, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + fence: 1, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + prompts: new CodexAcquisitionWindow().prompts, + options: new Map(), + reportedOptions: { model: 'gpt-live', effort: 'high' }, + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator: null + } +} diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index df7d8c4aae19..bb822d1cceb9 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -266,13 +266,12 @@ describe('Codex service tier across clients and rest', () => { expect( nativeSessionOptionsFromReport({ reported: { model: 'gpt-live', serviceTier: 'priority', fastMode: true }, - restoreSkipped: [], priorOptions: { fastMode: 'true', serviceTier: 'ultrafast' } }) ).toEqual({ model: 'gpt-live', serviceTier: 'priority' }) // An agent that reports Fast with no tier still keeps it. - expect( - nativeSessionOptionsFromReport({ reported: { fastMode: true }, restoreSkipped: [] }) - ).toEqual({ fastMode: 'true' }) + expect(nativeSessionOptionsFromReport({ reported: { fastMode: true } })).toEqual({ + fastMode: 'true' + }) }) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 2121cf50ad98..b84971cdf490 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -103,7 +103,7 @@ export async function acquireCodexStructuredSession(input: { throw new Error(`codex app-server for session ${sessionId} could not be stopped`) } acquisitions.assertCurrent(sessionId, attempt) - const launch = await deps + const resolvedLaunch = await deps .resolveLaunch({ identity: acquireInput.identity, spawnToken: acquireInput.spawnToken, @@ -112,6 +112,14 @@ export async function acquireCodexStructuredSession(input: { .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) + const { model: _savedNextModel, ...launchSettings } = resolvedLaunch + const launch = + acquireInput.options === undefined + ? resolvedLaunch + : { + ...launchSettings, + ...(acquireInput.options.model ? { model: acquireInput.options.model } : {}) + } acquisitions.assertCurrent(sessionId, attempt) account = launch.codexHome ? deps.resolveAccountKind?.(launch.codexHome) : undefined const connection = await open( diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a8d250bb1c5f..dc239fdb91b7 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,5 +1,5 @@ import * as codexRewind from './codex-structured-rewind' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -21,6 +21,7 @@ import { readLiveCodexSessionOptions, readCodexAcquisitionOptions } from './codex-structured-session-options' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { CodexAcquisitionRegistry, requireLiveCodexSession, @@ -223,19 +224,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap turnId ) - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - fence: number - requestedAt?: number - beforeDispatch?: () => Promise - }): Promise { + async dispatch( + input: Parameters[0] + ): Promise { const session = this.session(input.sessionId) session.dispatchPending = true try { await input.beforeDispatch?.() - return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + input.signal?.throwIfAborted() + const turnOptions = await waitForPromiseWithSignal( + resolvedCodexMessageOptions(session, input.executionOptions), + input.signal + ) + input.signal?.throwIfAborted() + return await dispatchCodexTurn(session, { ...input, turnOptions }, this.deps.requestTimeoutMs) } finally { session.dispatchPending = false } diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 693bd1fa9ccc..a9bd52325e74 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -6,11 +6,7 @@ import { fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' -import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' -import type { CodexAppServerConnection } from './codex-app-server-connection' -import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import { applyCodexStructuredSessionOption, readCodexStructuredSessionOptions, @@ -18,35 +14,10 @@ import { restoredCodexSessionOptions } from './codex-structured-session-options' import { reportedCodexThreadOptions } from './codex-structured-service-tier' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { startCodexTurn } from './codex-structured-turn-start' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' - -function optionSession(request: CodexAppServerConnection['request']): CodexSession { - return { - connection: { - pid: 1, - closed: false, - request, - notify: () => {}, - respond: () => {}, - respondWithError: () => {}, - close: async () => true - }, - backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), - ended: false, - fence: 1, - acquisitionGeneration: 'generation-1', - threadId: 'thread-1', - prompts: new CodexAcquisitionWindow().prompts, - options: new Map(), - reportedOptions: { model: 'gpt-live', effort: 'high' }, - dispatchEchoes: createCodexDispatchEchoes(), - turnOpenWaits: createCodexTurnOpenWaits(), - translator: null - } -} +import { optionSession } from './codex-structured-option-session.test-fixture' async function primePicker(session: CodexSession): Promise { session.catalogAccess = { diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 4355ed48c2cb..b98887ece3b6 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -118,6 +118,14 @@ export async function openCodexStructuredSession( prompts: attempt.window.prompts, options: restoredCodexSessionOptions(input.options), reportedOptions: reportedCodexThreadOptions(opened), + ...(launch.resumeThreadId === null && !launch.model && opened.model + ? { + nativeDefaults: { + model: opened.model, + ...(opened.effort ? { effort: opened.effort } : {}) + } + } + : {}), ...(catalogAccess ? { catalogAccess } : {}), dispatchEchoes: start.dispatchEchoes, translator, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 6de26ff66ae2..9a6664c864c5 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -151,6 +151,8 @@ export type CodexSession = { serviceTier?: string | null serviceTierKnown?: true } + /** A fresh thread opened without a model override proved these native defaults. */ + nativeDefaults?: { model: string; effort?: string } /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: CodexSessionCatalogAccess /** Sends whose identity is still to be settled by the provider echo. */ diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 52a653a68e3c..75955eaf5ae9 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -112,6 +112,7 @@ export async function startCodexTurn( body: AgentJournalMessageItem requestedAt?: number timeoutMs?: number + turnOptions?: Readonly> } ): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> { // Armed before the write: the echo and `turn/started` can both land while the @@ -133,17 +134,38 @@ export async function startCodexTurn( if (steered) { return steered } - const answer = await host.connection.request( - 'turn/start', - { - threadId: host.threadId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(input.body), - // Codex omits a tier the model does not list. - ...Object.fromEntries(host.options) - }, - { timeoutMs: input.timeoutMs } - ) + const previousOptions = input.turnOptions ? new Map(host.options) : null + if (input.turnOptions) { + host.options.clear() + for (const [key, value] of Object.entries(input.turnOptions)) { + host.options.set(key, value) + } + } + let answer: unknown + try { + answer = await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + // Codex omits a tier the model does not list. + ...(input.turnOptions ?? Object.fromEntries(host.options)) + }, + { timeoutMs: input.timeoutMs } + ) + } catch (error) { + if ( + previousOptions && + (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) + ) { + host.options.clear() + for (const [key, value] of previousOptions) { + host.options.set(key, value) + } + } + throw error + } return { turnId: readCodexTurnId(answer), via: 'start' } } @@ -160,6 +182,7 @@ export async function dispatchCodexTurn( clientMessageId: string body: AgentJournalMessageItem requestedAt?: number + turnOptions?: Readonly> }, timeoutMs: number | undefined ): Promise { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index da7593e91c7a..33b62355768a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -65,7 +65,7 @@ export type AgentModelCatalogConfiguredDefault = { * session can't say (it listed or runs no model); null when it runs a model it didn't list. */ export function unpickedSessionConfiguredChoice(session: { resolvesConfig: boolean - picked: ReadonlySet + picked?: ReadonlySet models: readonly { id: string }[] current: { model?: string; effort?: string } }): AgentModelCatalogConfiguredChoice | null | undefined { @@ -73,7 +73,7 @@ export function unpickedSessionConfiguredChoice(session: { // An empty listing (a signed-out agent) is doubt, not a catalog. if ( !session.resolvesConfig || - session.picked.has('model') || + session.picked?.has('model') || !current.model || models.length === 0 ) { @@ -85,7 +85,7 @@ export function unpickedSessionConfiguredChoice(session: { return { modelId: current.model, // An effort this session picked is its own and says nothing of the config's. - ...(session.picked.has('effort') ? {} : { effort: current.effort ?? null }) + ...(session.picked?.has('effort') ? {} : { effort: current.effort ?? null }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index e87fb450154c..0c1b9d7691fc 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -6,6 +6,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -107,6 +108,7 @@ export class JournalQueuedMessages { input: { messageId: string body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions fingerprint: string hostInstance: string requireAttachments?: true diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index c5306bb98696..791094587991 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -9,6 +9,7 @@ import type { AgentSessionJournalIdentity, AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' @@ -87,6 +88,7 @@ export function journalSubmissionRowBuilder( payloadFingerprint: string body: AgentJournalMessageItem fence: number + executionOptions?: AgentSessionExecutionOptions handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' @@ -276,6 +278,7 @@ export function buildJournalSubmissionRow(input: { payloadFingerprint: string providerHandle: AgentSessionJournalProviderHandle body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions seq: number fence: number ts: number @@ -290,6 +293,7 @@ export function buildJournalSubmissionRow(input: { payloadFingerprint: input.payloadFingerprint, providerHandle: input.providerHandle, body: input.body, + ...(input.executionOptions ? { executionOptions: input.executionOptions } : {}), ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index f018f6307f94..cbb6d615153e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -22,6 +22,7 @@ // sent-message kind. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, @@ -147,6 +148,7 @@ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrR * reconcile into instead of appending a second copy. */ export type JournalSubmissionRow = JournalRowBase & { kind: 'submission' + executionOptions?: AgentSessionExecutionOptions clientMessageId: string payloadFingerprint: string providerHandle: AgentSessionJournalProviderHandle diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index f9f7502ea8b8..f92acf0abf94 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -12,6 +12,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' @@ -94,6 +95,7 @@ export type JournalResolvedLifecycleBatchInput = Omit< } export type JournalSubmissionInput = { + executionOptions?: AgentSessionExecutionOptions clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 7b03b4f457f8..44f04800bb6e 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -6,6 +6,7 @@ import { type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { journalRenderItem } from './journal-render-item' import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import type { JournalReducerState } from './journal-reducer' @@ -16,8 +17,10 @@ export function applyJournalSubmission( state: JournalReducerState, row: Extract ): void { + const executionOptions = readAgentSessionExecutionOptions(row.executionOptions) state.submissions.set(row.clientMessageId, { clientMessageId: row.clientMessageId, + ...(executionOptions ? { executionOptions } : {}), fence: row.fence, payloadFingerprint: row.payloadFingerprint, dispatchState: 'pending', diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index 29fb7eb35fa2..89d49de68923 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -99,6 +99,7 @@ async function accept( body?: AgentJournalMessageItem origin?: 'client' | 'host' source?: AgentSessionMessageSource + executionOptions?: { model: string; effort: string } } = {} ): Promise { const body = fields.body ?? message(`text of ${id}`) @@ -109,7 +110,8 @@ async function accept( fence: 0, handoverRecorded: true, ...(fields.origin ? { origin: fields.origin } : {}), - ...(fields.source ? { source: fields.source } : {}) + ...(fields.source ? { source: fields.source } : {}), + ...(fields.executionOptions ? { executionOptions: fields.executionOptions } : {}) }) } @@ -159,6 +161,21 @@ afterEach(async () => { }) describe('which sends an earlier host process left unsent are kept', () => { + it('keeps an accepted message’s choices on its card through restart', async () => { + const executionOptions = { model: 'model-a', effort: 'high' } + const journal = await afterRestart((earlier) => + accept(earlier, 'chosen', { + origin: 'client', + source: USER_MESSAGE_SOURCE, + executionOptions + }) + ) + await hold(journal) + await journal.close() + const reopened = await open() + expect(reopened.queuedMessages.get('chosen')?.executionOptions).toEqual(executionOptions) + }) + it('keeps a person’s and a launch’s text, and an older build’s client send', async () => { const journal = await afterRestart(async (earlier) => { await accept(earlier, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index fd5a774c7bf2..2b862e6705dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -140,6 +140,9 @@ export async function holdUnsentSends( ? { messageId: clientMessageId, body: card.body, + ...(submission.executionOptions + ? { executionOptions: submission.executionOptions } + : {}), fingerprint: agentSessionSendBodyFingerprint( journal.queuedMessages.sessionId, card.body diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 3f9d0122fda2..4274a7f519d6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,7 +12,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'] + ['queued_sequence', 'INTEGER'], + ['execution_options_json', 'TEXT'] ] /** @@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, + execution_options_json TEXT, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index 51233b66abb0..faf9ca3a8665 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -5,6 +5,7 @@ import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-sessio import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { readStoredRejectionFact } from './journal-dispatch-reducer' import type { QueuedMessageRow } from './queued-message-table' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' /** A `queued_messages` row as this file's SELECTs return it; null when it cannot be read back. */ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | null { @@ -27,6 +28,7 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul carried_from: string | null queued_epoch: string | null queued_sequence: number | null + execution_options_json: string | null } let body: AgentJournalMessageItem try { @@ -46,8 +48,10 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul ) { return null } + const executionOptions = storedExecutionOptions(record.execution_options_json) return { sessionId: record.session_id, + ...(executionOptions ? { executionOptions } : {}), messageId: record.message_id, position: record.position, body, @@ -69,6 +73,17 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } +function storedExecutionOptions(json: string | null): QueuedMessageRow['executionOptions'] | null { + if (json === null) { + return null + } + try { + return readAgentSessionExecutionOptions(JSON.parse(json)) + } catch { + return null + } +} + /** Holds an earlier build stored that this one derives instead: a kept send (`kept`) waits under * the reopen's pause, a Stop's (`stopped`) under the Stop's. */ const QUEUED_MESSAGE_RETIRED_HOLD_REASONS: ReadonlySet = new Set(['kept', 'stopped']) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 9f2559804486..fa8361debbb9 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -17,6 +17,7 @@ import type { import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredQueuedMessageRow } from './queued-message-stored-row' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' @@ -36,6 +37,7 @@ export function isUnsettledQueuedMessage(row: Pick): export type QueuedMessageRow = { sessionId: string + executionOptions?: AgentSessionExecutionOptions messageId: string position: number body: AgentJournalMessageItem @@ -64,7 +66,7 @@ export type QueuedMessageRow = { } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, execution_options_json' export function insertQueuedMessage( db: Database.Database, @@ -72,6 +74,7 @@ export function insertQueuedMessage( sessionId: string messageId: string body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions fingerprint: string hostInstance: string queuedAt: AgentJournalCursor @@ -88,7 +91,7 @@ export function insertQueuedMessage( const position = input.position ?? Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -100,13 +103,15 @@ export function insertQueuedMessage( input.holdReason ?? null, null, input.queuedAt.epoch, - input.queuedAt.sequence + input.queuedAt.sequence, + input.executionOptions ? JSON.stringify(input.executionOptions) : null ) return { sessionId: input.sessionId, messageId: input.messageId, position, body: input.body, + ...(input.executionOptions ? { executionOptions: input.executionOptions } : {}), fingerprint: input.fingerprint, createdAt: input.now, hostInstance: input.hostInstance, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index c5c0d3a06f88..98ac767a664d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,23 +1,28 @@ // Concurrent host waits keep their own cancellation handles until they settle. export class StructuredAgentSessionAcquireAborts { - private readonly inFlight = new Map>() + private readonly inFlight = new Map< + string, + Set<{ controller: AbortController; handover: boolean }> + >() /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null /** For the attach about to run; `end` once it settles. */ - begin(sessionId: string): { signal: AbortSignal; end: () => void } { + begin(sessionId: string, handover = false): { signal: AbortSignal; end: () => void } { const controller = new AbortController() if (this.quitReason) { controller.abort(this.quitReason) } - const waits = this.inFlight.get(sessionId) ?? new Set() - waits.add(controller) + const waits = + this.inFlight.get(sessionId) ?? new Set<{ controller: AbortController; handover: boolean }>() + const active = { controller, handover } + waits.add(active) this.inFlight.set(sessionId, waits) return { signal: controller.signal, end: () => { - waits.delete(controller) + waits.delete(active) if (waits.size === 0 && this.inFlight.get(sessionId) === waits) { this.inFlight.delete(sessionId) } @@ -26,17 +31,20 @@ export class StructuredAgentSessionAcquireAborts { } /** A no-op when the session has nothing in flight. */ - abort(sessionId: string, reason: string | Error): void { - for (const controller of this.inFlight.get(sessionId) ?? []) { + abort(sessionId: string, reason: string | Error): boolean { + let stoppedHandover = false + for (const { controller, handover } of this.inFlight.get(sessionId) ?? []) { + stoppedHandover ||= handover && !controller.signal.aborted controller.abort(typeof reason === 'string' ? new Error(reason) : reason) } + return stoppedHandover } /** Quit: every start under way stops, and so does any the attach drain still runs. */ abortAll(reason: string): void { this.quitReason ??= new Error(reason) for (const waits of this.inFlight.values()) { - for (const controller of waits) { + for (const { controller } of waits) { controller.abort(this.quitReason) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index ffb50f0ef99b..6da0e33acc0d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -78,7 +78,6 @@ function attachParams( function adapter(input: { origin: 'created' | 'resumed' options?: AgentSessionOptionsResult - restoreFailures?: readonly string[] }): StructuredAgentSessionAdapter { return { acquire: vi @@ -99,9 +98,6 @@ function adapter(input: { } })), ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - ...(input.restoreFailures - ? { readOptionRestoreFailures: vi.fn(() => input.restoreFailures!) } - : {}), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -284,7 +280,7 @@ describe('structured session acquisition options', () => { expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) }) - it('persists provider options before proving a resumed legacy record', async () => { + it('preserves saved next-message choices when a provider reports its live options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) const store = await openTestAgentSessionRecordStore(root) @@ -349,19 +345,16 @@ describe('structured session acquisition options', () => { expect(reopened.getRecord(SESSION)?.options).toEqual({ approvalPolicy: 'on-request', personality: 'concise', - model: 'gpt-5.6-terra', - effort: 'medium', - fastMode: 'false' + fastMode: 'true' }) }) - it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { + it('keeps a saved Fast choice without an acquisition report', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] }, - restoreFailures: ['fastMode'] + options: { current: { model: 'gpt-standard' }, models: [] } }) const created = await performAttach({ @@ -386,10 +379,10 @@ describe('structured session acquisition options', () => { }) expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard' }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard', fastMode: 'true' }) }) - it('proves an opened Codex owner from effective options without reading its catalog', async () => { + it('proves an opened Codex owner without replacing saved choices from readback', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) const store = await openTestAgentSessionRecordStore(root) const sessionAdapter: StructuredAgentSessionAdapter = { @@ -420,7 +413,8 @@ describe('structured session acquisition options', () => { expect(created).toMatchObject({ ok: true }) expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-reported', effort: 'high' }) + expect(sessionAdapter.readAcquisitionOptions).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-saved', effort: 'medium' }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -459,50 +453,8 @@ describe('structured session acquisition options', () => { expect(sessionAdapter.readOptions).not.toHaveBeenCalled() }) - it('releases an acquisition when provider options cannot be read', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(root) - const releaseAcquisition = vi.fn(async () => true) - const failingAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - releaseAcquisition - } - - await expect( - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: failingAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - ).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() - }) - describe.each([ ['adapter acquire', 'acquire'], - ['options read', 'options'], ['identity commit', 'identity'], ['owner proof', 'proof'], ['journal attach', 'journal'] @@ -521,11 +473,8 @@ describe('structured session acquisition options', () => { }) const injected = new Error(`${failurePoint} failed`) const acquire = vi.mocked(base.acquire) - const readOptions = vi.mocked(base.readOptions!) if (failurePoint === 'acquire') { acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'options') { - readOptions.mockRejectedValueOnce(injected) } else if (failurePoint === 'identity') { vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) } else if (failurePoint === 'proof') { @@ -553,7 +502,7 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } + const failingAdapter = { ...base, acquire, releaseAcquisition } let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 01dc212ccdf4..d239c2c0c599 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,5 +1,4 @@ import { isDeepStrictEqual } from 'node:util' -import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import type { AgentSessionProcessIdentity, AgentSessionRecord @@ -12,8 +11,6 @@ import { import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' -import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' @@ -76,7 +73,8 @@ export async function acquireOwner( spawnToken, ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.acquireSignal ? { signal: input.acquireSignal } : {}) + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), + ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ @@ -94,27 +92,6 @@ export async function acquireOwner( } }) const providerChildPhase = acquired.providerChildPhase ?? 'ready' - // A starting child has proven nothing: the record keeps the reservation's saved options as - // intent, never a catalog guess, and the `started` event persists what the child reports. - const options = - providerChildPhase === 'starting' - ? undefined - : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { - const read = { - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - } - // Still inside the start, so the limit or a Stop ends a read the provider never answers. - return waitForPromiseWithSignal( - Promise.resolve( - input.adapter.readAcquisitionOptions - ? input.adapter.readAcquisitionOptions(read) - : readNativeSessionOptions({ adapter: input.adapter, ...read }) - ), - input.acquireSignal - ) - }) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -129,8 +106,7 @@ export async function acquireOwner( sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), - now: input.now(), - ...(options ? { options } : {}) + now: input.now() }) if (providerChildPhase === 'ready' && acquired.catalogListing) { handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index c64908eaeb0f..971556a942a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -193,9 +193,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi : readNativeSessionOptions({ adapter, ...input }) } - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 331d9951c689..5191cf1a6ea4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -48,6 +48,7 @@ export type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' @@ -282,6 +283,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string clientMessageId: string body: AgentJournalMessageItem + /** Immutable choices of this submission; adapters apply them only to this dispatch. */ + executionOptions?: AgentSessionExecutionOptions + /** Host-local cancellation while choices are applied before prompt admission. */ + signal?: AbortSignal fence: number /** Host clock on the submission row this send came from; the origin the turn * it opens records as `requestedAt`. */ @@ -405,8 +410,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & | Promise> | undefined> | Readonly> | undefined - /** Option keys skipped after a provider rejected their persisted restore value. */ - readOptionRestoreFailures?(sessionId: string): readonly string[] /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 2c180c121b33..71745937eb19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -171,9 +171,16 @@ async function startStructuredAgentSessionAgent( let acquisitionError: unknown let aborted = false let expired = false + const startupOptions = startedFor + ? context.sessions + .get(sessionId) + ?.journal.submissions() + .find((submission) => submission.clientMessageId === startedFor)?.executionOptions + : undefined try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), + ...(startupOptions ? { startupOptions } : {}), onAcquisitionFailed: (error) => { acquisitionError = error }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index bd6806730c66..3f49fbdd0c23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -76,6 +76,7 @@ export type AttachFlowInput = { ) => StructuredAgentSessionStartupProgress /** A completed acquisition's start listing. */ onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void + startupOptions?: Readonly> /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( record: AgentSessionRecord diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 915fc6453374..bde49559a843 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -49,6 +49,7 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + startupOptions?: Readonly> /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ onAborted?: () => void /** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */ @@ -205,6 +206,7 @@ async function runAttachUnderAbort( attempt.startup = startup return startupAttempts.track(sessionId, startup) }, + ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts index 46bab41cceb3..a98da27a185c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts @@ -44,6 +44,7 @@ export function captureChatStopTarget( child: StructuredAgentSessionProviderChild | null turnId?: string endsSession: boolean + stoppedPrePrompt?: boolean } ): ChatStopTarget | null { const { child, turnId } = input @@ -68,7 +69,7 @@ export function captureChatStopTarget( // A Stop naming no turn ends nothing more unless the session reads working, by the rule every // session list and the chat's own Stop read it, over the fold as it stands. if (!child || (turnId === undefined && !isMainAgentWorking(ctx))) { - return queued ? { ...target, reach: 'withdraw', marks: true } : null + return queued || input.stoppedPrePrompt ? { ...target, reach: 'withdraw', marks: true } : null } // Repeating the Stop in force with nothing sent since writes no second event, so a card queued // between the presses sends normally, as after one Stop. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index b9ed2c93be54..95ad3f0791db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -29,7 +29,11 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { acceptChatStop, acceptStopTarget } from './structured-agent-session-stop-acceptance' -import { noteStructuredAgentSessionStopUnconfirmed } from './structured-agent-session-stop-unconfirmed-note' +import { + noteStructuredAgentSessionStopUnconfirmed, + writeStructuredAgentSessionStopNote +} from './structured-agent-session-stop-unconfirmed-note' +import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' import { endStoppedStructuredAgentSession, type StructuredAgentSessionStopWindDown @@ -46,7 +50,7 @@ export type StructuredAgentSessionChatStopRun = { outcome: ChatStopOutcome; ends export function mutateWithChatStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope; turnId?: string }, + params: { envelope: AgentSessionMutationEnvelope; turnId?: string; stoppedPrePrompt?: boolean }, plan: MutationPlan, run: ( ctx: AgentSessionTurnContext, @@ -89,7 +93,13 @@ export function mutateWithChatStop( }) }) return accepted.ok - ? { ok: true, value: { settled: target.queued, turnId: accepted.value.turnId } } + ? { + ok: true, + value: { + settled: target.queued || params.stoppedPrePrompt === true, + turnId: accepted.value.turnId + } + } : accepted } /** After the acceptance commits: only while what it captured still stands. */ @@ -140,7 +150,8 @@ export function mutateWithChatStop( const target = captureChatStopTarget(ctx, { child: context.sessions.get(ctx.sessionId)?.child ?? null, ...named, - endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true, + stoppedPrePrompt: params.stoppedPrePrompt }) if (!target) { // Late, or nothing to stop: answered as a no-op, its receipt committed before the answer. @@ -150,6 +161,14 @@ export function mutateWithChatStop( if (!accepted.ok) { return accepted } + if (params.stoppedPrePrompt && target.reach === 'withdraw') { + await writeStructuredAgentSessionStopNote( + ctx, + accepted.value.turnId ?? envelope.clientOperationId, + { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, + ctx.journal.liveTurnScope() + ) + } try { return await act(ctx, target, accepted.value.settled) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts deleted file mode 100644 index 6b50d0bc03f1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-claude' } -const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' -const DEFAULT_MODEL = 'sonnet' -const PICKED_MODEL = 'opus' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let activeModel: string -let activeEffort: string | null -let dispatch: Mock -let order: string[] -let setOption: Mock -let releaseOpen: () => void - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function adapter(): StructuredAgentSessionAdapter { - setOption = vi.fn(async ({ key, value }) => { - order.push(`${key}=${value}`) - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } - return { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } - }) - dispatch = vi.fn(async () => { - order.push('dispatch') - return { state: 'admitted' as const } - }) - acquire = vi.fn(async ({ fence, spawnToken, options }) => { - activeModel = options?.model ?? DEFAULT_MODEL - return { - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `claude-native-${fence}`, - handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - return { - open: () => - new Promise((resolve) => { - releaseOpen = () => - resolve({ - reportedOptions: { model: DEFAULT_MODEL } - }) - }), - acquire, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption, - readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), - closeSession: vi.fn(async () => { - activeModel = DEFAULT_MODEL - return true - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - order = [] - releaseOpen = () => {} - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-claude', - now: () => NOW - }) - expect( - await host.attach( - CALLER, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' } - }) - ) - ).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)) - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) -}) - -it('applies model and effort picks on a running Claude chat to the next message', async () => { - releaseOpen() - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - for (const [key, value] of [ - ['model', PICKED_MODEL], - ['effort', 'high'] - ]) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - ).toMatchObject({ ok: true }) - } - const body = hostTestMessage('with the new choices') - dispatch.mockImplementationOnce(async () => { - expect(activeModel).toBe(PICKED_MODEL) - expect(setOption).toHaveBeenCalledWith( - expect.objectContaining({ key: 'effort', value: 'high' }) - ) - return { state: 'admitted' as const } - }) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['model=opus', 'effort=high']) -}) - -it('saves a waiting startup pick when Stop ends the child before it is ready', async () => { - const fields = { key: 'model', value: PICKED_MODEL } - const picked = host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - try { - expect( - await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - ).toMatchObject({ ok: true }) - expect(await picked).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe(PICKED_MODEL) - expect(setOption).not.toHaveBeenCalled() - } finally { - releaseOpen() - } -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts new file mode 100644 index 000000000000..43b7dca105de --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function publishRunningTurn(first: string): Promise { + rig.providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 99 }, + { + kind: 'turn', + turnId: 'turn-a', + state: 'running', + userItemId: agentJournalSubmissionKey(first) + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) +} + +it.each([ + { label: 'pause', change: { kind: 'status', status: 'paused' } }, + { label: 'edit', change: { kind: 'set', objective: 'Updated objective' } }, + { label: 'clear', change: { kind: 'clear' } } +] as const)( + 'runs a goal $label while Codex holds an earlier send for different choices', + async ({ change }) => { + const changeGoal = vi.fn(async () => ({ ok: true as const })) + rig.host.deps.adapter.changeThreadGoal = changeGoal + const fields = { key: 'model', value: 'model-a' } + expect( + await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + const first = await rig.workingSend() + await rig.settleAccepted(first, 'a') + await publishRunningTurn(first) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn() + ).toMatchObject({ state: 'running', userItemId: agentJournalSubmissionKey(first) }) + const held = rig.send('Use a different model', undefined, { + executionOptions: { model: 'model-b' } + }) + expect(await held.result).toMatchObject({ ok: true }) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(rig.dispatch).toHaveBeenCalledOnce() + const goal = rig.host.changeThreadGoal(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ change }, 'agentSession.threadGoal', hostTestOperationId()), + change + }) + await eventually(() => expect(changeGoal).toHaveBeenCalledOnce()) + expect(await goal).toMatchObject({ ok: true }) + expect(changeGoal).toHaveBeenCalledWith(expect.objectContaining({ change })) + expect(rig.dispatch).toHaveBeenCalledOnce() + expect(await rig.submission(held.id)).toMatchObject({ dispatchState: 'pending' }) + expect((await rig.submission(held.id))?.handedOverAt).toBeUndefined() + } +) + +it('releases a startup goal when a second message parks while the first turn opens', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const changeGoal = vi.fn(async () => ({ ok: true as const })) + rig.host.deps.adapter.changeThreadGoal = changeGoal + const first = rig.send('First model', undefined, { executionOptions: { model: 'model-a' } }) + const second = rig.send('Second model', undefined, { executionOptions: { model: 'model-b' } }) + await first.result + await second.result + const change = { kind: 'set' as const, objective: 'A startup objective' } + const goal = rig.host.changeThreadGoal(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ change }, 'agentSession.threadGoal', hostTestOperationId()), + change + }) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + await rig.proveStart() + await eventually(() => expect(changeGoal).toHaveBeenCalledOnce()) + expect(await goal).toMatchObject({ ok: true }) + expect(rig.dispatch).toHaveBeenCalledOnce() + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 424107e39c93..9db7a657354f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -68,7 +68,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false } - const closed = await closeStructuredAgentSessionConversationUnderSerialize( + return closeStructuredAgentSessionConversationUnderSerialize( { sessions, closeStatus: (id) => { @@ -80,7 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - return closed } const idleSweep = new StructuredAgentSessionIdleSweep({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index fe2f6450dbd3..f123079892b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -220,6 +220,7 @@ describe('a Stop that names no turn', () => { const catalogReads = codex.connections[0].calls.filter( (call) => call.method === 'model/list' ).length + await pickOption('serviceTier', 'priority') const first = send('first send') await first.result await eventually(() => @@ -235,9 +236,7 @@ describe('a Stop that names no turn', () => { const followUp = send('follow-up') await followUp.result - await eventually(() => - expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(true) - ) + expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(false) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) codex.connections[0].handlers.onNotification?.('turn/completed', { threadId: THREAD_ID, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 710b758a97a3..0ce12f92dad5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -42,6 +42,8 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionDeliveryLoopDeps = { deliveryOrder: StructuredAgentSessionDeliveryOrder @@ -76,10 +78,9 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** A person's Stop is still ending the session's work: the status feed's own reading. */ stopping: (sessionId: string) => boolean now: () => number + acquireAborts: Pick } -type Step = 'continue' | 'stop' - type RefusedStart = Extract type Prepared = @@ -241,7 +242,7 @@ export class StructuredAgentSessionDeliveryLoop { sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity | null, waited: boolean - ): Promise { + ): Promise<'continue' | 'stop'> { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -290,6 +291,12 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } } + if ( + this.deps.record(sessionId)?.provider === 'codex' && + waitsForCodexTurnOptions(session.journal, next) + ) { + return this.stop(sessionId) + } await handOverSubmission( { sessionId, @@ -300,7 +307,8 @@ export class StructuredAgentSessionDeliveryLoop { failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), childWork: () => this.deps.readChildWork(sessionId), - now: this.deps.now + now: this.deps.now, + acquireAborts: this.deps.acquireAborts }, next ) 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 new file mode 100644 index 000000000000..8e275ca1646d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -0,0 +1,40 @@ +import { + readAgentSessionExecutionOptions, + type AgentSessionExecutionOptions +} from '../../../shared/agent-session-execution-options' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-options-read' + +/** Capture once, while acceptance holds the conversation's ordered lane. */ +export function captureStructuredExecutionOptions( + ctx: Pick, + supplied?: AgentSessionExecutionOptions +): { options: AgentSessionExecutionOptions } | { refusal: AgentSessionWireRefusal } { + const saved = ctx.persistedOptions ?? {} + const candidate = supplied ?? { + ...(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 } : {}) + } + const options = readAgentSessionExecutionOptions(candidate) + if (!options) { + return { + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + 'This message has an invalid choice.' + ) + } + } + const refusal = structuredAgentSessionOptionOverridesRefusal( + ctx.agents, + ctx.agent, + Object.fromEntries( + Object.entries(options).filter(([key]) => !(ctx.agent === 'codex' && key === 'fastMode')) + ) + ) + return refusal ? { refusal } : { options } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index efb568dbc13f..13ff868b528a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -27,6 +27,7 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se import { recoverStructuredRewind } from './structured-rewind-recovery' import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -53,6 +54,7 @@ export type StructuredAgentSessionConversationDelivery = { export function createStructuredAgentSessionConversationDelivery(input: { deps: StructuredAgentSessionHostDeps + acquireAborts: Pick sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise @@ -125,7 +127,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, stopping: input.clientDelivery.readStopping, - now: () => deps.now?.() ?? Date.now() + now: () => deps.now?.() ?? Date.now(), + acquireAborts: input.acquireAborts }) const adoptOpened = async ( sessionId: string, 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 abf82c9e604b..c19d875715fe 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 @@ -7,9 +7,8 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' -import { performSetOption } from './structured-agent-session-turns-options' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -66,6 +65,7 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` * origin, so a restart or a close keeps it as a card if it never reached the @@ -138,12 +138,13 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params - if (!prompt && params.turnId === undefined) { - abortAcquireForStop(context, caller, params.envelope, plan) - } + const stoppedPrePrompt = + !prompt && params.turnId === undefined + ? abortAcquireForStop(context, caller, params.envelope, plan) + : false // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). - const stopped = prompt ? { envelope: params.envelope } : params + const stopped = prompt ? { envelope: params.envelope } : { ...params, stoppedPrePrompt } return mutateWithChatStop(context, caller, stopped, plan, (ctx, stop) => prompt ? cancelStructuredAgentSessionPrompt( @@ -164,7 +165,7 @@ function abortAcquireForStop( caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, plan: MutationPlan -): void { +): boolean { const { store } = context.deps if ( !agentSessionMutationAdmitsNow({ @@ -175,9 +176,9 @@ function abortAcquireForStop( now: context.now }) ) { - return + return false } - context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') + return context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') } export function respondToStructuredAgentSessionPrompt( @@ -199,51 +200,17 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { - const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID const plan = setOptionPlan(params) - const atRest = () => { - const child = context.sessions.get(params.envelope.sessionId)?.child - return recordOnly || !child || child.phase === 'starting' || child.close !== undefined - } - return runWithReadyStructuredAgentSessionOperation( + return mutateStructuredAgentSession( context, - caller.callerKey, + caller, params.envelope, - plan, - (selection) => - runStructuredAgentSessionMutation( - context, - caller, - params.envelope, - { - ...plan, - get conversationWrite() { - return atRest() ? (true as const) : undefined - }, - run: async (ctx) => { - if (atRest()) { - return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - } - const wait = context.acquireAborts.begin(params.envelope.sessionId) - try { - return await performSetOption(ctx, params, wait.signal) - } finally { - wait.end() - } - } - }, - (ledger) => - atRest() - ? openForWrite(context, params.envelope)() - : openWithReadyStructuredAgentSessionChild(context, params.envelope, selection)(ledger) - ), { - needsAgent: () => { - const child = context.sessions.get(params.envelope.sessionId)?.child - return !recordOnly && Boolean(child && !child.close) - }, - runAfterAgentEnds: true - } + ...plan, + conversationWrite: true, + run: (ctx) => recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + }, + openForWrite(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index c110624de9e1..6a66fa068495 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -527,7 +527,7 @@ describe('respondToPrompt', () => { }) describe('setOption', () => { - it('goes to the provider and writes nothing to the journal', async () => { + it('saves a next-message pick without changing the provider', async () => { await attach() setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) const fields = { key: 'model', value: 'gpt-5' } @@ -538,20 +538,20 @@ describe('setOption', () => { const result = await host.setOption(CALLER, params) expect(result).toMatchObject({ ok: true, - value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + value: { ...fields, options: { model: 'gpt-5' } } }) expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true, replayed: true, - value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + value: { ...fields, options: { model: 'gpt-5' } } }) - expect(setOption).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5' }) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) - it('does not turn an unknown provider outcome into a successful replay', async () => { + it('replays the accepted pick without contacting the provider', async () => { await attach() setOption.mockRejectedValueOnce(new Error('reply lost')) const fields = { key: 'model', value: 'gpt-5' } @@ -560,12 +560,12 @@ describe('setOption', () => { ...fields } - await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true }) expect(await host.setOption(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } + ok: true, + replayed: true }) - expect(setOption).toHaveBeenCalledTimes(1) + expect(setOption).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 109fdbdb71d4..883928cdfab2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,11 +6,10 @@ import { StructuredConversationCommandController } from './structured-conversati // only through `conversation`, which opens it at rest; an agent is started only by work that needs // it, and the idle sweep is the one thing that puts it to rest. -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' -import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' @@ -118,6 +117,7 @@ export class StructuredAgentSessionHost { }) this.conversationDelivery = conversation.createStructuredAgentSessionConversationDelivery({ deps, + acquireAborts: this.runtimeState.acquireAborts, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. @@ -331,7 +331,7 @@ export class StructuredAgentSessionHost { /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are * revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */ - journalSnapshot = async (sessionId: string): Promise => + journalSnapshot = async (sessionId: string) => (await this.lifetime.conversation(sessionId)).journal.snapshot() subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) 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 new file mode 100644 index 000000000000..f6df02978f8f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function pickModel(model: string): Promise { + const fields = { key: 'model', value: model } + const result = await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + expect(result).toMatchObject({ ok: true }) +} + +it('keeps the first accepted choice through opening and saves a later pick for the next send', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const first = rig.send('first', undefined, { executionOptions: { model: 'model-a' } }) + expect(await first.result).toMatchObject({ ok: true }) + await pickModel('model-b') + const second = rig.send('second') + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.setOption).not.toHaveBeenCalled() + await rig.proveStart() + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + expect(rig.dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ model: 'model-a' }) + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toEqual({ model: 'model-b' }) + expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') +}) + +it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { + await pickModel('model-b') + rig.dispatch.mockRejectedValueOnce( + withProviderDiagnostic( + new AgentSessionOptionRejectedError('model-a is unavailable'), + providerDiagnostic('model-a is unavailable', 'person') + ) + ) + const first = rig.send('first', undefined, { executionOptions: { model: 'model-a' } }) + expect(await first.result).toMatchObject({ ok: true }) + await eventually(async () => + expect(await rig.submission(first.id)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringContaining('model-a is unavailable') + }) + ) + expect(rig.dispatch).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') +}) + +it('uses each send’s captured choices when two messages arrive during startup', async () => { + const releaseStart = rig.holdNextStart() + const first = rig.send('first', undefined, { + executionOptions: { model: 'model-a', effort: 'high' } + }) + const second = rig.send('second', undefined, { + executionOptions: { model: 'model-b', effort: 'low' } + }) + expect(await first.result).toMatchObject({ ok: true }) + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.dispatch).not.toHaveBeenCalled() + releaseStart() + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions)).toEqual([ + { model: 'model-a', effort: 'high' }, + { model: 'model-b', effort: 'low' } + ]) + expect(rig.setOption).not.toHaveBeenCalled() +}) + +it('keeps a draft’s accepted choices through a host restart and Send now', async () => { + const working = await rig.workingSend() + const choices = { model: 'model-b', effort: 'low' } + const queued = await rig.send('use these choices', 'queue-if-active', { + executionOptions: choices + }).result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.executionOptions).toEqual(choices) + await rig.settleAccepted(working, 'a') + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + expect((await rig.handoff(draftId))?.executionOptions).toEqual(choices) +}) + +it('steers a matching choice but holds an empty default choice for a new turn', async () => { + const choices = { model: 'model-a', effort: 'high' } + const first = rig.send('first', undefined, { executionOptions: choices }) + await first.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await rig.settleAccepted(first.id, 'a') + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-a', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + + const matching = rig.send('same choices', undefined, { executionOptions: choices }) + await matching.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + await rig.host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: matching.id, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 1 } + }) + const defaults = rig.send('default choices', undefined, { executionOptions: {} }) + await defaults.result + expect(rig.dispatch).toHaveBeenCalledTimes(2) + + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-a', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({}) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts new file mode 100644 index 000000000000..41e9fe7a9898 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -0,0 +1,38 @@ +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +const OPTION_ORDER = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const + +function sameOptions( + a: AgentSessionExecutionOptions | undefined, + b: AgentSessionExecutionOptions | undefined +): boolean { + if ((a === undefined) !== (b === undefined)) { + return false + } + return OPTION_ORDER.every((key) => a?.[key] === b?.[key]) +} + +/** A different Codex choice needs a new turn: steer has no choice parameters. */ +export function waitsForCodexTurnOptions( + journal: Pick, + next: AgentJournalSubmission +): boolean { + const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) + if (body?.kind !== 'message' || body.command) { + return false + } + const turn = journal.newestTurn() + if (turn?.state !== 'running') { + return false + } + const active = journal.submissions().find((submission) => { + if (submission.handedOverAt === undefined) { + return false + } + return agentJournalSubmissionKey(submission.clientMessageId) === turn.userItemId + }) + return active ? !sameOptions(active.executionOptions, next.executionOptions) : true +} 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 b7a7121a4e85..d859cd6fd015 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 @@ -6,13 +6,14 @@ // others' current answer. import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_MESSAGE_SOURCE, USER_MESSAGE_SOURCE, type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionCancelResult, @@ -36,7 +37,6 @@ import { import { performPrompt, performSend, - performSetOption, type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' @@ -139,6 +139,7 @@ export function sendPlan(params: { body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions userSend?: true /** A person's message the host sends for them; `userSend` is always one. */ personsMessage?: true @@ -161,7 +162,7 @@ export function sendPlan(params: { recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. - run: (ctx) => { + run: async (ctx) => { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() const source = sendSource(params) @@ -171,7 +172,8 @@ export function sendPlan(params: { clientMessageId, // 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 + body: params.body, + executionOptions: params.executionOptions }) }, replay: (ctx, outcome) => { @@ -337,9 +339,13 @@ export function setOptionPlan(params: { return { method: 'agentSession.setOption', fields: { key: params.key, value: params.value }, - run: (ctx) => performSetOption(ctx, params), - // A pending row may have crashed before the adapter call. Reapplying the - // same assignment is safe; only a settled success can be answered directly. + run: async (ctx) => { + const options = { ...ctx.persistedOptions, [params.key]: params.value } + await ctx.persistOptions(options) + ctx.publish() + return { ok: true, value: { ...params, options } } + }, + // Reapply unsettled picks; only a settled success can be replayed directly. replay: (ctx, outcome) => outcome.status === 'succeeded' ? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index b1ec8d997a93..7eccfe5f22ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -3,7 +3,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' export async function readNativeSessionOptions(input: { - adapter: Pick + adapter: Pick sessionId: string fence: number priorOptions?: Readonly> @@ -15,16 +15,13 @@ export async function readNativeSessionOptions(input: { } return nativeSessionOptionsFromReport({ reported: reported.current, - restoreSkipped: adapter.readOptionRestoreFailures?.(sessionId) ?? [], ...(priorOptions ? { priorOptions } : {}) }) } -/** The record's options once the provider has reported: its model, effort, Fast and service tier - * replace the saved ones, other saved options stay, and any the child could not take are dropped. */ +/** Legacy acquisition facts replace execution choices; unrelated saved launch fields survive. */ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] - restoreSkipped: readonly string[] priorOptions?: Readonly> /** Values the child showed it cannot run: its report of the same value is not kept. */ retired?: Readonly> @@ -35,9 +32,6 @@ export function nativeSessionOptionsFromReport(input: { delete restored.effort delete restored.fastMode delete restored.serviceTier - for (const key of input.restoreSkipped) { - delete restored[key] - } // A reported tier is the speed itself; the Fast it implies for older clients is not kept. const fastMode = reported.fastMode === undefined || reported.serviceTier !== undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 1876416e7d5a..df310b364a86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -22,7 +22,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -91,8 +91,8 @@ function adapter(): StructuredAgentSessionAdapter { return { supportsLocation: () => true, acquire, - dispatch: vi.fn(async () => { - dispatchedModels.push(activeModel) + dispatch: vi.fn(async ({ executionOptions }) => { + dispatchedModels.push(executionOptions?.model ?? activeModel) return { state: 'accepted', providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } @@ -136,7 +136,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: router, @@ -178,7 +178,7 @@ describe('structured session options and close', () => { expect(events).toEqual([ { type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle' })] + sessions: [expect.objectContaining({ status: 'idle', optionSelection: {} })] } ]) expect(events[0]).not.toHaveProperty('sessions.0.model') @@ -236,5 +236,6 @@ it('applies running Codex model and effort picks to the next message', async () await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) - expect(activeEffort).toBe('high') + expect(vi.mocked(router.dispatch).mock.calls.at(-1)?.[0].executionOptions?.effort).toBe('high') + expect(router.setOption).not.toHaveBeenCalled() }) 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 647c08ca9758..cfc6f921524e 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 @@ -123,8 +123,8 @@ describe('live Codex option reads', () => { it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() + const record = { ...restingRecord(), options: { model: 'gpt-picked', effort: 'high' } } const child = { fence: 7 } - const record = restingRecord() const apply = vi.fn(() => ({ models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], current: { model: 'gpt-live', effort: 'high', confirmed: ['model', 'effort'] as const } @@ -151,7 +151,9 @@ describe('live Codex option reads', () => { conversation: async () => live } as unknown as StructuredAgentSessionMutationContext - const reading = readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + const reading = readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) await vi.waitFor(() => expect(prepareReadOptions).toHaveBeenCalledWith({ sessionId: SESSION, fence: 7 }) ) @@ -162,7 +164,7 @@ describe('live Codex option reads', () => { record.options = { model: 'gpt-next', effort: 'low' } listing.resolve() await expect(reading).resolves.toMatchObject({ - current: { model: 'gpt-next', effort: 'low', confirmed: [] } + current: { model: 'gpt-next', effort: 'low' } }) expect(apply).toHaveBeenCalledOnce() }) 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 83943eb314b6..04aa53cf8c45 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 @@ -1,9 +1,7 @@ // A chat's options, live or at rest. // -// At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — -// and the list is the host's model catalog, the same one the picker already reads before a chat -// exists. A pick made at rest is written to the record as intent, through the same transition a live -// pick takes, so the next start applies it. +// The record keeps the next message's choices; the catalog names known defaults. A provider's +// reports remain execution facts and never replace the saved selection. import { saveStructuredAgentSessionListing } from './structured-agent-session-start-listing' import { @@ -29,7 +27,10 @@ import { structuredAgentSessionLaunchPermission } from './structured-agent-sessi import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -type RestingOptions = Pick +type RestingOptions = Pick< + AgentSessionOptionsResult, + 'models' | 'modelsKnown' | 'fastModeSupport' | 'current' +> /** What the asking client takes: whether it reads an answer that names no model. */ export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } @@ -134,6 +135,7 @@ async function readStructuredAgentSessionOptionsAtRest( : undefined) return { models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], + modelsKnown: catalog.origin !== 'unknown', ...(catalog.origin !== 'unknown' && catalog.fastModeSupport ? { fastModeSupport: catalog.fastModeSupport } : {}), @@ -237,26 +239,30 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null - const saved = record?.options - const selected = - live && (saved?.model !== undefined || saved?.effort !== undefined) - ? await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) - : options - const current = { - ...options.current, - ...(saved?.model !== undefined ? { model: selected.current.model } : {}), - ...(saved?.effort !== undefined ? { effort: selected.current.effort } : {}) - } - const confirmed = current.confirmed?.filter((key) => - key === 'model' - ? current.model === options.current.model - : key !== 'effort' || current.effort === options.current.effort - ) + const selected = live + ? await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) + : options + const confirmed = options.current.confirmed?.filter((key) => { + if (key === 'model') { + return selected.current.model === options.current.model + } + if (key === 'effort') { + return selected.current.effort === options.current.effort + } + if (key === 'fastMode') { + return selected.current.fastMode === options.current.fastMode + } + if (key === 'serviceTier') { + return selected.current.serviceTier === options.current.serviceTier + } + return false + }) return { ...options, current: { - ...current, - ...(confirmed ? { confirmed } : {}), + ...selected.current, + ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), + ...(confirmed?.length ? { confirmed } : {}), ...(permissionMode ? { permissionMode } : {}) }, rewind: diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index f4ffba515285..21244820e413 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -201,12 +201,10 @@ describe('a Claude chat whose CLI is still starting', () => { }) it('gives a message held while starting a full idle window after startup lands to open its turn', async () => { + claude = fakeClaude({ replayUuid: null }) await attachStarting() const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. - connection.send = async (message) => { - connection.sent.push(message) - } const held = await send('sent while starting') expect(connection.sent).toEqual([]) expect(await dispatchState(held)).toBe('pending') @@ -234,7 +232,7 @@ describe('a Claude chat whose CLI is still starting', () => { await host.flushStreamedEvents(SESSION) await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - expect(await dispatchState(held)).toBe('accepted') + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) }) it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index e0e6c3130027..2831b63153a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -159,8 +159,7 @@ export function noteStructuredAgentSessionProviderStarted( } } -/** What a ready child reports later, such as a read that came after its start: persisted as the - * start's report is, never ahead of a send. */ +/** A ready child's later read can enrich the saved account catalog. */ export async function settleStructuredAgentSessionOptionsReported( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionOptionsReportedEvent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 10a720c8ce52..bdd6be6144a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -51,6 +51,7 @@ export function createQueuedRigProvider( const cancelTurn: Mock = vi.fn(async () => ({ cancelled: true })) + const setOption: Mock = vi.fn(async () => undefined) const closeSession: Mock> = vi.fn( async () => true ) @@ -101,7 +102,7 @@ export function createQueuedRigProvider( ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), + setOption, ...(options.rewind ? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) } : {}), @@ -155,6 +156,7 @@ export function createQueuedRigProvider( failNextStart, compact, cancelTurn, + setOption, closeSession, finishCompact, providerEvents diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 0d4c821007f8..f78d27579045 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -10,6 +10,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' @@ -35,7 +36,11 @@ import { import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } -type RigSendOptions = { internal?: true; from?: AgentMessageSource } +type RigSendOptions = { + internal?: true + from?: AgentMessageSource + executionOptions?: AgentSessionExecutionOptions +} export function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -55,7 +60,7 @@ export async function createQueuedMessageTestRig( resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const provider = createQueuedRigProvider(store, options, () => host) - const { dispatch, compact, cancelTurn, closeSession } = provider + const { dispatch, compact, cancelTurn, closeSession, setOption } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -100,6 +105,7 @@ export async function createQueuedMessageTestRig( envelope: envelope(fields, 'agentSession.send', clientOperationId), ...fields, body, + ...(options?.executionOptions ? { executionOptions: options.executionOptions } : {}), ...(options?.internal ? {} : { userSend: true as const }) }) return { id: clientOperationId, result } @@ -279,6 +285,7 @@ export async function createQueuedMessageTestRig( return host }, dispatch, + setOption, cancelTurn, closeSession, compact, 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 ac85a5ed8bfb..ad53238edccf 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 @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionSendResult, @@ -33,6 +34,7 @@ import { nextSendableQueuedCard } from '../agent-session-journal/queued-message- import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' +import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' /** Text-only v1: any image block routes to the immediate path. */ export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean { @@ -176,11 +178,15 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: Pick, + ctx: Pick< + AgentSessionTurnContext, + 'sessionId' | 'journal' | 'fence' | 'operationReceipt' | 'agents' | 'agent' | 'persistedOptions' + >, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** A person's send at a chat surface: every attachment it names must still be stored. */ userSend?: true /** A person's message the host sends for them. */ @@ -222,6 +228,10 @@ export async function maybeQueueStructuredAgentSessionSend( if (refusal) { return { ok: false, refusal } } + const captured = captureStructuredExecutionOptions(ctx, params.executionOptions) + if ('refusal' in captured) { + return { ok: false, refusal: captured.refusal } + } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. let row: QueuedMessageRow @@ -230,6 +240,7 @@ export async function maybeQueueStructuredAgentSessionSend( { messageId: clientMessageId, body: params.body, + executionOptions: captured.options, // In the session that will send it: the reducer aliases the provider's echo by exactly this. fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), hostInstance: structuredAgentSessionHostInstance(), @@ -357,6 +368,7 @@ export class StructuredAgentSessionQueuedMessageDrain { origin: 'host', payloadFingerprint: next.fingerprint, body: next.body, + ...(next.executionOptions ? { executionOptions: next.executionOptions } : {}), fence, handoverRecorded: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index a3fee6e2a339..62d62262b12c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -166,6 +166,7 @@ export function sendQueuedStructuredAgentMessage( origin: 'client', payloadFingerprint: row.fingerprint, body: row.body, + ...(row.executionOptions ? { executionOptions: row.executionOptions } : {}), fence: ctx.fence, handoverRecorded: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index d48d9bf27c39..808cd1a83513 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -52,6 +52,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess const held = row.state === 'waiting' && row.holdReason !== null published.push({ messageId: row.messageId, + ...(row.executionOptions ? { executionOptions: row.executionOptions } : {}), position: row.position, body: row.body, state: row.state, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index d472e159fcbc..cbcbeafae3c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -3,6 +3,7 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -14,6 +15,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions userSend?: true personsMessage?: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index de87449c26dd..39a663aa299a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -91,7 +91,6 @@ export async function runWithReadyStructuredAgentSessionOperation( record: AgentSessionRecord | null ) => AgentSessionMutationSessionPreparation | null needsAgent?: (record: AgentSessionRecord | null) => boolean - runAfterAgentEnds?: true } = {} ): Promise> { const hostFingerprint = computeAgentSessionPayloadFingerprint({ @@ -180,11 +179,7 @@ export async function runWithReadyStructuredAgentSessionOperation( return { ok: true as const, child: identity, deadline } } const readiness = context.startupAttempts.readiness(envelope.sessionId, identity) - return readiness - ? { ok: true as const, child: identity, readiness } - : options.runAfterAgentEnds - ? { ok: true as const, child: identity } - : operationDidNotRun() + return readiness ? { ok: true as const, child: identity, readiness } : operationDidNotRun() } const child = await select().catch((error: unknown) => { context.deps.logger.warn('selecting an agent for an operation failed', { @@ -223,7 +218,7 @@ export async function runWithReadyStructuredAgentSessionOperation( ), deadline - Date.now() ) - if (outcome !== 'ready' && !(outcome === 'ended' && options.runAfterAgentEnds)) { + if (outcome !== 'ready') { return operationDidNotRun(outcome === 'timeout') } if (Date.now() >= deadline) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index e443c55b9eb8..72944ea397b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AGENT_SESSION_WIRE_REFUSAL_CODES, type AgentSessionMutationEnvelope, @@ -199,13 +198,6 @@ async function assertHostAgreement( return `${spec.method}:${code}` } -async function setLease( - harness: Harness, - update: (record: AgentSessionRecord) => AgentSessionRecord -): Promise { - await harness.store.transitionHandoff(SESSION, update) -} - // sendPlan and setOptionPlan have no unsupported branch. const UNREACHABLE = new Set([ 'agentSession.send:structured_agent_session_unsupported', @@ -222,6 +214,10 @@ const UNREACHABLE = new Set([ 'agentSession.setOption:agent_session_journal_unreadable', // Only a send restarts a lost owner. 'agentSession.setOption:agent_session_owner_restart_failed', + // Saving a next-message pick is a conversation write, independent of its child owner. + 'agentSession.setOption:agent_session_conflict', + 'agentSession.setOption:execution_owner_reconciling', + 'agentSession.setOption:agent_session_operation_unknown', // A write names its target, not an owner generation; only an attach compares fences. 'agentSession.setOption:agent_session_checkpoint_stale', 'agentSession.send:agent_session_checkpoint_stale', @@ -248,25 +244,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) } - expect(stale.setOption).toHaveBeenCalledTimes(1) - - const conflict = await createHarness() - for (const method of ['agentSession.setOption'] as const) { - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'new-owner-proving' } - })) - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(conflict, spec, 'agent_session_conflict', async () => { - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: null } - })) - return { harness: conflict, spec } - }) - ) - } + expect(stale.setOption).not.toHaveBeenCalled() const absent = await createHarness({ attached: false }) for (const method of METHODS) { @@ -317,10 +295,6 @@ describe('agentSessionRefusalOperationState host oracle', () => { } const unknown = await createHarness() - unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) - const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } - await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') - record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) openTestJournalHostDatabase(unknown.root) @@ -330,24 +304,6 @@ describe('agentSessionRefusalOperationState host oracle', () => { .run(sendUnknown.operationId) record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - const reconciling = await createHarness() - for (const method of ['agentSession.setOption'] as const) { - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: false } - })) - return { harness: reconciling, spec } - }) - ) - } - const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') const unreadableOpen = vi diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index f7e6388f92e2..6b37cbde714e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -159,11 +159,11 @@ describe('a press sent again', () => { await pick('B') expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B']) + expect(setOption).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.options?.model).toBe('B') expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B', 'A']) + expect(setOption).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.options?.model).toBe('A') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 228bf2b66cd6..01dc17733798 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -25,13 +25,15 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock +let setOption: Mock beforeEach(() => { - ;({ store, host, dispatch } = hostTestState()) + ;({ store, host, dispatch, setOption } = hostTestState()) }) function hostJournal(): AgentSessionJournal { @@ -57,6 +59,67 @@ async function delivered(clientMessageId: string, options: { handedOver?: true } } describe('send', () => { + it('passes each accepted message choice to its provider dispatch and keeps replay immutable', async () => { + await attach() + const first = hostTestMessage('first') + const second = hostTestMessage('second') + const a = { + envelope: envelope('agentSession.send', { body: first }), + body: first, + executionOptions: { model: 'model-a', effort: 'high' } + } + const b = { + envelope: envelope('agentSession.send', { body: second }), + body: second, + executionOptions: { model: 'model-b', effort: 'low' } + } + await host.send(CALLER, a) + await delivered(a.envelope.clientOperationId) + await host.send(CALLER, b) + await delivered(b.envelope.clientOperationId) + + expect(dispatch.mock.calls.map(([input]) => input.executionOptions)).toEqual([ + a.executionOptions, + b.executionOptions + ]) + expect(dispatch).toHaveBeenCalledTimes(2) + expect( + hostJournal() + .submissions() + .map((submission) => submission.executionOptions) + ).toEqual([a.executionOptions, b.executionOptions]) + await expect( + host.send(CALLER, { ...a, executionOptions: b.executionOptions }) + ).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(setOption).not.toHaveBeenCalled() + }) + + it('settles a refused message choice without dispatching its prompt', async () => { + await attach() + dispatch.mockRejectedValueOnce(new AgentSessionOptionRejectedError('model unavailable')) + const body = hostTestMessage('do not send') + const params = { + envelope: envelope('agentSession.send', { body }), + body, + executionOptions: { model: 'model-unavailable' } + } + const result = await host.send(CALLER, params) + expect(result).toMatchObject({ ok: true }) + await vi.waitFor(() => { + expect( + hostJournal() + .submissions() + .find((submission) => submission.clientMessageId === params.envelope.clientOperationId) + ).toMatchObject({ dispatchState: 'rejected' }) + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(setOption).not.toHaveBeenCalled() + }) + it('writes the submission before dispatching and resolves it accepted', async () => { await attach() const body = hostTestMessage('add a retry') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 13bd46a60d35..936a2bd202f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -1,4 +1,3 @@ -import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' // Every agent this build registers starts under the host's one startup attempt and its hold on // delivery. Iterates the runtime's real registration list, so an agent registered later is covered // without being named here. @@ -33,13 +32,6 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l const CALLER = { callerKey: 'client-1' } -// Temporary, removed when every adapter publishes at spawn: adapters whose own acquire already does, -// with the test that drives the real one. Every other registration still runs its handshake inside -// acquire (the bridge) until then. -const REAL_ADAPTER_PUBLISHES_AT_SPAWN: Readonly> = { - claude: 'structured-agent-session-claude-hung-start-stop.test.ts' -} - let root: string | null = null afterEach(async () => { @@ -65,7 +57,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ store: AgentSessionRecordStore acquire: Mock dispatch: Mock - opened: PromiseWithResolvers + ready: () => void }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() @@ -92,9 +84,12 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) - const opened = Promise.withResolvers() + let ready = () => {} const adapter: StructuredAgentSessionAdapter = { - open: () => opened.promise, + open: () => + new Promise((resolve) => { + ready = () => resolve({ reportedOptions: {} }) + }), acquire, dispatch, closeSession: vi.fn(async () => true), @@ -118,7 +113,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW }) - return { host, store, acquire, dispatch, opened } + return { host, store, acquire, dispatch, ready: () => ready() } } describe.each( @@ -127,7 +122,7 @@ describe.each( ) )('%s starts under the host attempt', (agent, definition) => { it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { - const { host, store, acquire, dispatch, opened } = await hostFor(definition) + const { host, store, acquire, dispatch, ready } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -168,9 +163,7 @@ describe.each( ) expect(dispatch).not.toHaveBeenCalled() - opened.resolve({ - reportedOptions: { model: 'default' } - }) + ready() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) } finally { @@ -178,11 +171,3 @@ describe.each( } }) }) - -describe.each( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent).filter( - (agent) => !REAL_ADAPTER_PUBLISHES_AT_SPAWN[agent] - ) -)('%s with its own adapter', () => { - it.todo('publishes at spawn instead of finishing its handshake first') -}) 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 83b9c7a2a172..4b16f3f12ad6 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 @@ -1,14 +1,4 @@ -// One start of a conversation's provider child, minted by the host before any adapter acquires. -// -// Whatever a start means as policy belongs to the host and is the same for every agent: which -// conversation and lease it is for, what cancels it, when it gives up, and when its child may take -// input. An adapter only runs its protocol inside the attempt it is handed. -// -// The clock starts when the process exists, never before (resolving a launch is not starting it). -// A start that says nothing for a minute is wedged; one still talking is given up only at the -// ceiling, so a slow migration or history replay is not killed mid-way while every start still -// ends. Expiry re-derives what to end from the host's own state, so a timer that outlives its -// attempt ends nothing. +// The host owns start policy; expiry rechecks its current child before ending a stale attempt. import { randomUUID } from 'node:crypto' import { @@ -35,8 +25,10 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal + startupOptions?: Readonly> }): StructuredAgentSessionStartupAttempt { const { record } = input + const options = input.startupOptions ?? record.options return { attemptId: randomUUID(), identity: input.identity, @@ -47,7 +39,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { accountHome: record.accountHome, ...(record.launchDirectory === undefined ? {} : { launchDirectory: record.launchDirectory }) }, - ...(record.options ? { options: record.options } : {}), + ...(options ? { options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), ...(input.signal ? { signal: input.signal } : {}) @@ -302,14 +294,13 @@ export class StructuredAgentSessionStartupAttempts { tracked.expiry = this.measure(tracked, reason) } tracked.expired = true - const { child } = tracked // A published child's stop is under way and ends nothing a newer attempt owns; one still // acquiring stays tracked until its aborted acquire is abandoned or published. - if (child) { + if (tracked.child) { this.report(tracked.expiry) this.end(sessionId) } - this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child }) + this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child: tracked.child }) } private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { 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 46a62a05dc49..7cad7d0d9363 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 @@ -1,6 +1,7 @@ // Which status-summary changes reach every session list. The summary is broadcast to remote // subscribers too, so only a change a session list shows may re-send it. +import { structuralValuesEqual } from '../../../shared/structural-value-equality' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import { agentSessionBackgroundTasksEqual, @@ -32,6 +33,7 @@ export function structuredStatusSummariesEqual( a.latestPrompt === b.latestPrompt && a.model === b.model && a.permissionMode === b.permissionMode && + structuralValuesEqual(a.optionSelection, b.optionSelection) && 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 5edc115cdcca..270583abdec0 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 @@ -62,6 +62,7 @@ export function structuredAgentSessionStatusSummary({ : {}), ...(model ? { model } : {}), ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), + ...(record ? { optionSelection: record.options ?? {} } : {}), ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index b9ed60ea1bbb..7893173556f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' // A Stop's event, through the real host: written in the Stop's serialized step as it is accepted, // before the interrupt and before anything that ends the child, naming the turn, who asked and // the target it captured; never by a Stop that stopped nothing. @@ -48,10 +49,16 @@ async function queuedDraft(text: string): Promise { } /** The provider's turn row for the working send, which may land after a Stop. */ -async function turnRow(turnId: string, state: 'running' | 'interrupted') { +async function turnRow(turnId: string, state: 'running' | 'interrupted', userId?: string) { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, - { kind: 'turn', turnId, state, startedAt: 1 }, + { + kind: 'turn', + turnId, + state, + startedAt: 1, + ...(userId ? { userItemId: agentJournalSubmissionKey(userId) } : {}) + }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -191,7 +198,7 @@ describe("a Stop's event", () => { const working = await rig.workingSend() await rig.stop() const between = await queuedDraft('queued between the presses') - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toHaveLength(1) await rig.settleAccepted(working, 'stopped') @@ -201,10 +208,10 @@ describe("a Stop's event", () => { it('a second press after a card sent into the turn settled unknown writes again', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const working = await rig.workingSend() await rig.stop() // The turn the working send started opens after that turnless Stop; the card goes into it. - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -223,7 +230,7 @@ describe("a Stop's event", () => { const working = await rig.workingSend() await rig.stop() // The turn the working send started opens after that turnless Stop; the card goes into it. - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -238,8 +245,8 @@ describe("a Stop's event", () => { it('naming no turn, names the turn running when it takes effect', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() - await turnRow('turn-1', 'running') + const working = await rig.workingSend() + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) }) @@ -261,7 +268,7 @@ describe("a Stop's event", () => { it('names a turn that ended while the next card is sent but shows no turn yet: writes nothing, and the card runs', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const next = await queuedDraft('sent when turn-1 ends') await rig.settleAccepted(working, 'working') await turnRow('turn-1', 'interrupted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts deleted file mode 100644 index ffee03165f3f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' -import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' - -export async function performSetOption( - ctx: AgentSessionTurnContext, - input: { key: string; value: string }, - signal?: AbortSignal -): Promise> { - let applied: void | Readonly> - try { - applied = await ctx.adapter.setOption({ - sessionId: ctx.sessionId, - ...input, - fence: ctx.fence, - ...(signal ? { signal } : {}) - }) - } catch (error) { - if (isAgentSessionOptionRejectedError(error)) { - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: error.refusalReason }, - error.message - ) - } - } - throw error - } - await ctx.persistOptions(applied ?? { [input.key]: input.value }) - ctx.publish() - return { ok: true, value: { ...input, ...(applied ? { options: { ...applied } } : {}) } } -} 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 8c79c9290235..2b400d71b511 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 @@ -6,7 +6,15 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + providerDiagnosticOf +} from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { abortSignalReason } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -45,7 +53,6 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import type { StructuredAgentRegistry } from './structured-agent-registry' -export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -111,17 +118,44 @@ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number + requestedAt: number, + executionOptions: AgentSessionExecutionOptions | undefined, + signal?: AbortSignal ): Promise { try { - return await ctx.adapter.dispatch({ + const outcome = await ctx.adapter.dispatch({ sessionId: ctx.sessionId, clientMessageId, body, + ...(executionOptions ? { executionOptions } : {}), + ...(signal ? { signal } : {}), fence: ctx.fence, requestedAt }) + return signal?.aborted && outcome.state === 'rejected' + ? { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + } + : outcome } catch (error) { + if (signal?.aborted && error === abortSignalReason(signal)) { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + } + } + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }), + { surface: 'rejection' } + ) + } + } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } } @@ -141,6 +175,7 @@ export async function performSend( input: { clientMessageId: string payloadFingerprint: string + executionOptions?: AgentSessionExecutionOptions body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' @@ -163,9 +198,13 @@ export async function performSend( value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } + const captured = captureStructuredExecutionOptions(ctx, input.executionOptions) + if ('refusal' in captured) { + return { ok: false, refusal: captured.refusal } + } try { await ctx.journal.appendSubmission( - { ...input, fence: ctx.fence, handoverRecorded: true }, + { ...input, executionOptions: captured.options, fence: ctx.fence, handoverRecorded: true }, undefined, ctx.operationReceipt ) @@ -202,7 +241,9 @@ export async function performSend( } } -export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { + acquireAborts?: Pick +} /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -236,12 +277,20 @@ export async function handOverSubmission( }) // The handover row's instant on the host clock; the turn this dispatch opens records it so the // live counter never re-anchors at turn-open. - const outcome = await dispatchSafely( - ctx, - clientMessageId, - body, - structuredAgentSessionHandoverOrigin(ctx.journal, submission) - ) + const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + let outcome: AgentSessionDispatchOutcome + try { + outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + structuredAgentSessionHandoverOrigin(ctx.journal, submission), + submission.executionOptions, + attempt?.signal + ) + } finally { + attempt?.end() + } // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index e671fc96d9f4..811736c0a332 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -378,7 +378,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B void state.host['tasks'].serialize(SESSION, () => held.promise) const commanded = state.host.conversationCommand(CALLER, params) void state.host['tasks'].serialize(SESSION, async () => { - events?.appendItem( + await events?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, { kind: 'turn', turnId: 'provider-turn', state: 'running' }, { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index 3091f3096f7b..c6e684dc4827 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -297,15 +297,25 @@ describe('Pi’s default comes from what a chat with no pick runs', () => { ).toEqual(NOTHING) }) - it('learns nothing from a chat that restored a pick or resumed a conversation', async () => { + it('learns its configured default despite a saved next-message pick, but not from a resumed conversation', async () => { const { catalog, chat } = await piHost() await catalog.read({ agent: 'pi', waitForListing: true }) const clean = workspace('clean') - expect((await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing).not.toHaveProperty( - 'configuredDefault' - ) - expect((await chat(clean, { resumed: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) + expect( + (await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing.configuredDefault + ).toEqual({ + modelId: 'openai/gpt-6', + effort: 'high' + }) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ + model: 'openai/gpt-6', + effort: 'high' + }) + const resumed = await piHost() + await resumed.catalog.read({ agent: 'pi', waitForListing: true }) + expect( + (await resumed.chat(workspace('resumed'), { resumed: true })).listing + ).not.toHaveProperty('configuredDefault') }) it('saves no default from a signed-out Pi, which lists no model', async () => { @@ -399,10 +409,10 @@ describe('Pi’s default comes from what a chat with no pick runs', () => { }) }) - it('learns the model, but not the thinking level, from a chat that restored only its thinking level', async () => { + it('learns the configured model and thinking level despite a saved next-message thinking pick', async () => { const { chat } = await piHost() const { listing } = await chat(workspace('clean'), { saved: { effort: 'low' } }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6' }) + expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) }) it('learns from a new Pi session standing in for a conversation whose file was gone', async () => { diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts new file mode 100644 index 000000000000..c172ba451233 --- /dev/null +++ b/src/main/pi/rpc-message-dispatch.ts @@ -0,0 +1,112 @@ +import { + agentSessionFailureFact, + providerDiagnostic, + withProviderDiagnostic +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { applyPiRpcSessionOption } from './rpc-options' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import type { PiRpcSession, PiRpcSessionDeps } from './rpc-session' + +type DispatchInput = Parameters[0] + +function exited(): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Pi', + surface: 'rejection' + }) + } +} + +export async function dispatchPiRpcMessage( + session: PiRpcSession, + input: DispatchInput, + logger: PiRpcSessionDeps['logger'], + isCurrent: () => boolean +): Promise { + let prompt + try { + prompt = await preparePiRpcPrompt(input.body) + } catch (error) { + logger.warn('Pi prompt could not be prepared', { + scope: 'pi-prompt', + sessionId: input.sessionId, + error + }) + return { + state: 'rejected', + ...agentSessionFailureWords( + error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError + ? error.failure + : agentSessionFailureFact('attachmentUnreadable'), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } + const model = input.executionOptions?.model ?? session.initialOptions.model + if (!input.executionOptions) { + input.signal?.throwIfAborted() + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) + } + for (const key of ['model', 'effort'] as const) { + const value = + input.executionOptions?.[key] ?? + (key === 'model' || model === session.initialOptions.model + ? session.initialOptions[key] + : undefined) + if (value === undefined && session.selected.has(key)) { + throw new AgentSessionOptionRejectedError( + new Error(`The default ${key} for this model is unknown`) + ) + } + if ( + value === undefined || + session.selected.get(key) === value || + (!session.selected.has(key) && session.initialOptions[key] === value) + ) { + continue + } + try { + await waitForPromiseWithSignal( + applyPiRpcSessionOption(session.connection, session.selected, key, value), + input.signal + ) + } catch (error) { + if (session.connection.closed) { + return exited() + } + if (error instanceof JsonlRpcResponseError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(error.message, 'person') + ) + } + throw error + } + } + input.signal?.throwIfAborted() + if (!isCurrent() || session.connection.closed) { + return exited() + } + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) +} diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 004a21b5e6be..75f7966f139d 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -125,7 +125,7 @@ describe('Pi RPC session ownership and delivery', () => { origin: 'created', mintedAtFence: 7 }) - expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['unknown', 'effort']) + expect(h.connection.requests).not.toContain('set_thinking_level') expect(h.connection.requests).toContain('get_commands') }) @@ -152,6 +152,22 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent[0]).toMatchObject({ type: 'prompt', message: 'hello' }) }) + it('applies each sent model before effort and before prompting Pi', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + const result = await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'choice-send', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, + executionOptions: { model: 'anthropic/model-2', effort: 'low' } + }) + expect(result).toEqual({ state: 'admitted' }) + expect(h.connection.requests.slice(-2)).toEqual(['set_model', 'set_thinking_level']) + expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) + }) + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { const h = await setup() await expect( diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 6dc725194c29..a70f4419a46d 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -1,7 +1,5 @@ import { randomUUID } from 'node:crypto' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { AgentSessionPreSpawnError, AgentSessionAcquisitionRootExitObservedError, @@ -17,10 +15,9 @@ import { compactPiRpcSession } from './rpc-compaction' import { buildPiRpcLaunch } from './rpc-launch' import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' -import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import { dispatchPiRpcMessage } from './rpc-message-dispatch' import { applyPiRpcSessionOption } from './rpc-options' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' -import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & { resolveLaunch: (input: { @@ -137,30 +134,11 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => { const session = this.session(input.sessionId, input.fence) - let prompt - try { - prompt = await preparePiRpcPrompt(input.body) - } catch (error) { - this.deps.logger.warn('Pi prompt could not be prepared', { - scope: 'pi-prompt', - sessionId: input.sessionId, - error - }) - return { - state: 'rejected', - ...agentSessionFailureWords( - error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError - ? error.failure - : agentSessionFailureFact('attachmentUnreadable'), - { agentName: 'Pi', surface: 'rejection' } - ) - } - } - return session.turns.submit( - input.clientMessageId, - input.requestedAt ?? Date.now(), - { ...prompt }, - input.beforeDispatch + return dispatchPiRpcMessage( + session, + input, + this.deps.logger, + () => this.sessions.get(input.sessionId) === session ) } @@ -237,9 +215,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } readOptions: NonNullable = async (input) => this.session(input.sessionId, input.fence).readOptions() - readOptionRestoreFailures(id: string): readonly string[] { - return this.sessions.get(id)?.startup.skipped ?? [] - } readCommands = (id: string) => this.sessions.get(id)?.startup.commands holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index b246ea9b9aab..e4db9740f20a 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -5,7 +5,6 @@ import type { AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' -import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAcquireInput @@ -17,12 +16,7 @@ import { type AgentModelCatalogLiveListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' -import { - applyPiRpcSessionOption, - piModelOptionId, - readPiRpcCommands, - readPiRpcSessionOptions -} from './rpc-options' +import { piModelOptionId, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' @@ -32,7 +26,6 @@ import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agen const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } export class PiRpcSessionStartup { - skipped: readonly string[] = [] commands?: AgentSessionSlashCommand[] /** What the start listed; a signed-out Pi lists no model. */ options?: AgentSessionOptionsResult @@ -40,7 +33,6 @@ export class PiRpcSessionStartup { constructor( private readonly input: StructuredAgentSessionAcquireInput, private readonly connection: PiRpcConnection, - private readonly selected: Map, private readonly deps: PiRpcSessionDeps, /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ private readonly resolvesConfig: boolean, @@ -70,31 +62,6 @@ export class PiRpcSessionStartup { model: state.model ? piModelOptionId(state.model.provider, state.model.id) : '', ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}) } - const saved = this.input.options ?? {} - const skipped = Object.keys(saved).filter((key) => key !== 'model' && key !== 'effort') - // Keys a restore sent, whether or not Pi took them. - const picked = new Set() - for (const key of ['model', 'effort'] as const) { - const value = saved[key] - if (value === undefined) { - continue - } - picked.add(key) - try { - this.assertLive(signal) - await this.wait( - applyPiRpcSessionOption(this.connection, this.selected, key, value, START_REQUEST), - signal - ) - current[key] = value - } catch (error) { - if (!(error instanceof JsonlRpcResponseError)) { - throw error - } - skipped.push(key) - this.warn('Pi rejected a saved option', 'pi-option-restore', error, { key }) - } - } this.assertLive(signal) current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model @@ -110,10 +77,12 @@ export class PiRpcSessionStartup { this.options = options catalogListing = withLiveCatalogListing( options, - unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, picked, ...options }) + unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + ...options + }) ).catalogListing } - this.skipped = skipped await this.readCommands(signal) this.assertLive(signal) return { diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 406a7cfb08df..ca335b156a47 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -64,6 +64,7 @@ export class PiRpcSession { readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() readonly startup: PiRpcSessionStartup + initialOptions: Readonly> = {} openedLink: AgentSessionProviderHandleLink | null = null requestedClose = false private publishedExit = false @@ -148,7 +149,6 @@ export class PiRpcSession { this.startup = new PiRpcSessionStartup( input, this.connection, - this.selected, deps, resolvesConfig, (state) => { @@ -160,6 +160,10 @@ export class PiRpcSession { async open(signal: AbortSignal): Promise { const result = await this.startup.run(this.resolvedLaunch, signal) + this.initialOptions = { + ...(result.reportedOptions.model ? { model: result.reportedOptions.model } : {}), + ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) + } this.openedLink = result.link ?? null return result } diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index bef2bc424efa..86e87486cca0 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -39,20 +39,6 @@ it('fails option hydration before ownership can be proved', async () => { ).rejects.toThrow('model list unavailable') }) -it('drops provider-rejected persisted options before the next owner proof', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ models: [], current: { model: 'provider-model' } }), - readOptionRestoreFailures: () => ['permissionMode'] - }, - sessionId: SESSION, - fence: 2, - priorOptions: { permissionMode: 'retired-mode', other: 'keep' } - }) - ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) -}) - it('omits empty model values from other option producers', async () => { await expect( readNativeSessionOptions({ diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index 528d8a8b7c7d..0d20297fa9ee 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -85,7 +85,8 @@ export function fakeClaude(providerSession: string) { stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) }, - send: async (message) => { + send: async (message, beforeDispatch) => { + await beforeDispatch?.() connection.sent.push(message) if (message.type === 'user') { handlers.onMessage?.({ ...message, uuid: 'user-1' }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 0aad485b09b3..7b25402fd62b 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -535,7 +535,8 @@ describe('a structured Claude session over agentSession.*', () => { return false } // Claude takes the message but crashes before echoing it. - connection.send = async (message) => { + connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() connection.sent.push(message) } const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index bb958dfa745c..99cbb35f09be 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -171,14 +171,14 @@ describe('a Claude start whose CLI answers initialize but not a control request' await ready(host) expect(record(host)?.options).toEqual({ model: 'sonnet' }) // The picker offers the saved model, which nothing has vouched for yet. - expect(await picker(host)).toEqual({ model: 'sonnet', effort: 'high', confirmed: ['effort'] }) + expect(await picker(host)).toEqual({ model: 'sonnet', effort: undefined, confirmed: undefined }) // A turn reports its own model; the picker and record keep the next message's saved choice. turnReportsModel('claude-sonnet-5') expect(await picker(host)).toEqual({ model: 'sonnet', - effort: 'high', - confirmed: ['effort'] + effort: undefined, + confirmed: undefined }) expect(record(host)?.options).toEqual({ model: 'sonnet' }) diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index 464c7bb2e84f..df6cbd279f69 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -241,7 +241,10 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: MODEL.model } + }) attachParams.envelope.clientOperationId = operationId() const attached = await host.attach(CALLER, attachParams) if (!attached.ok) { @@ -350,9 +353,10 @@ describe("a message queued behind a send whose turn hasn't opened", () => { expect(openWaits.turnIds).toEqual([]) turns.start() + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.echo(first) await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) await vi.waitFor(() => expect(steers).toBe(1)) - turns.echo(first) says('working on the first', 'reply-1') says('more on both', 'reply-2') @@ -386,8 +390,8 @@ describe("a message queued behind a send whose turn hasn't opened", () => { expect((await submission(b))?.handedOverAt).toBeUndefined() turns.start() - await vi.waitFor(() => expect(steers).toBe(2)) turns.echo(zero) + await vi.waitFor(() => expect(steers).toBe(2)) says('working on zero', 'reply-0') expect(await rowScope(a)).toMatchObject({ kind: 'turn' }) @@ -497,17 +501,19 @@ describe('a send whose turn Codex answered and never opened', () => { expect(verdictOf((await settled()).submissions, first)).toBe('pending') expect((await submission(second))?.handedOverAt).toBeUndefined() turns.start() - await vi.waitFor(() => expect(steers).toBe(1)) turns.echo(first) + await vi.waitFor(() => expect(steers).toBe(1)) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, first)).toBe('accepted') ) }) - // Steered in the moment the turn opens, it can be echoed before the send that opened the turn. - it('keeps the turn, and its live status, on the send that opened it when the steer echoes first', async () => { + // The opener's echo identifies the choices a matching follow-up may steer into. + it('keeps the turn and its live status on the opener when a later steer echoes', async () => { const { first, second } = await heldBehindAnAnsweredSend() turns.start() + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.echo(first) await vi.waitFor(() => expect(steers).toBe(1)) turns.echo(second) @@ -515,7 +521,7 @@ describe('a send whose turn Codex answered and never opened', () => { expect(verdictOf((await settled()).submissions, second)).toBe('accepted') ) - expect(verdictOf((await settled()).submissions, first)).toBe('pending') + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') const rows = await drawn() expect(rows.map((row) => row.text)).toEqual(['look around', 'and check the tests']) expect(new Set(rows.map((row) => row.turn))).toEqual( diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index f41061392fd6..0b1bd318c2ed 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -283,7 +283,10 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: MODEL.model } + }) attachParams.envelope.clientOperationId = operationId() const attached = await host.attach(CALLER, attachParams) if (!attached.ok) { @@ -299,6 +302,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +async function runningTurn(): Promise { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + return opening +} + describe('a Codex send its turn ended without taking it', () => { it('is withdrawn by a Stop before any echo, and nothing reads as working after', async () => { const sent = await send('look around') @@ -324,10 +335,7 @@ describe('a Codex send its turn ended without taking it', () => { }) it('withdraws a follow-up Codex steered into the turn a Stop ends, with no working latch', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() const followUp = await send('and check the tests') // Steered into the running turn by name, which fires no second turn/started. await vi.waitFor(() => expect(steers).toBe(1)) @@ -363,10 +371,8 @@ describe('the turn a withdrawn Codex send was answered into', () => { } } - it("is that turn's record, started by the send that opened it and steered by a later one", async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() + it('keeps the turn identity of a steering send after the opening message is identified', async () => { + const opening = await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) @@ -377,9 +383,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { const { turnRecords } = await answeredInto(opening) expect(turnRecords).toHaveLength(1) - const started = { turnItemId: turnRecords[0], via: 'start' } const steeredIn = { turnItemId: turnRecords[0], via: 'steer' } - expect(await answeredInto(opening)).toEqual({ turnRecords, named: started, onPage: started }) expect(await answeredInto(steered)).toEqual({ turnRecords, named: steeredIn, @@ -388,10 +392,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { }) it('is not named on a send the turn took', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() await stop('turn-1') await vi.waitFor(async () => @@ -442,10 +443,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { } it('comes back as a paused waiting card, and nothing sends it again', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const { messageId: cardId } = await queueThenSendNow('and check the tests') // Steered into the running turn, with no echo yet. await vi.waitFor(() => expect(steers).toBe(1)) @@ -489,10 +487,7 @@ describe('a Codex before 0.148, which names a steered start falsely', () => { () => (method, params) => handlers?.onNotification?.(method, params), { legacyStartAnswers: true } ) - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const followUp = await send('and check the tests') await vi.waitFor(() => expect(steers + answers).toBe(2)) @@ -510,13 +505,6 @@ describe('a Codex before 0.148, which names a steered start falsely', () => { }) describe("a queued card's Send-now while a Codex turn runs", () => { - async function runningTurn(): Promise { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) - } - it('goes into that turn as turn/steer, and the turn settles it when Codex echoes it', async () => { await runningTurn() @@ -558,12 +546,7 @@ describe("a queued card's Send-now while a Codex turn runs", () => { }) describe('a second send made after Codex answered the first, before it opened that turn', () => { - it('joins that turn once it opens, on a Codex that names a steered start falsely, so a Stop withdraws both', async () => { - turns = codexTurnLifecycleFake( - THREAD, - () => (method, params) => handlers?.onNotification?.(method, params), - { legacyStartAnswers: true } - ) + it('waits for the opener before steering the later send, which Stop then withdraws', async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const followUp = await send('and check the tests') @@ -573,6 +556,14 @@ describe('a second send made after Codex answered the first, before it opened th await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) expect(openWaits.turnIds).toEqual([]) turns.start() + await host.flushStreamedEvents(SESSION) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn()?.state).toBe( + 'running' + ) + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) + expect(steers).toBe(0) + expect(verdictOf((await settled()).submissions, followUp)).toBe('pending') + turns.echo(opening) await vi.waitFor(() => expect(steers).toBe(1)) expect(answers).toBe(1) @@ -582,7 +573,7 @@ describe('a second send made after Codex answered the first, before it opened th expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') ) const after = await settled() - expect(verdictOf(after.submissions, opening)).toBe('withdrawn') + expect(verdictOf(after.submissions, opening)).toBe('accepted') expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') expect(after.owesWork).toBe(false) }) @@ -864,10 +855,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { // Handed over once the stopped turn read ended, it started its own turn, however late that // turn's own end then arrives; a second Stop finds its turn never opened. it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() interruptEndHeld = true await stop() interruptEndHeld = false @@ -884,10 +872,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { // Codex drains a steer into the running turn, so it may hold one it never echoed. it('leaves a send steered into the open turn in doubt when the child end follows', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) interruptsFail = true diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 954f995dc86d..01c70a3f2138 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -17,10 +17,8 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import type { AgentJournalRenderItem, AgentJournalSubmission @@ -61,16 +59,15 @@ const JOURNAL_IDENTITY = { agent: 'codex' as const, providerHandle: codexProviderHandle(THREAD) } -// The capability set the desktop renderer advertises. Without the pending-send +// An eager-start client with current options capabilities. Without the pending-send // one the host holds the reply until the send settles, which is a shim for // clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) } // ─── the fake `codex app-server` ──────────────────────────────────────────── @@ -96,7 +93,7 @@ type FakeConnection = Omit & { function fakeCodex(): CodexScript { const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, @@ -147,7 +144,7 @@ function fakeCodex(): CodexScript { } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } const live = (): FakeConnection => { const connection = connections.at(-1) if (!connection) { @@ -318,6 +315,20 @@ async function historyPage( return (response as { result: AgentSessionHistoryResult }).result } +async function readKnownOptions(): Promise { + return vi.waitFor(async () => { + const result = await call('agentSession.options', { sessionId: SESSION }) + expect(result).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + return result + }) +} + beforeEach(async () => { operations = 0 root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) @@ -337,7 +348,7 @@ beforeEach(async () => { providerHandle: _providerHandle, ...resolved } = attachParams(null) - return resolved + return { ...resolved, options: { model: 'gpt-live' } } }, publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => @@ -443,13 +454,7 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) + await readKnownOptions() }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -535,14 +540,7 @@ describe('a structured codex session over agentSession.*', () => { expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await call('agentSession.options', { sessionId: SESSION }) - expect(options).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) + await readKnownOptions() await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), key: 'model', @@ -553,10 +551,12 @@ describe('a structured codex session over agentSession.*', () => { key: 'effort', value: 'high' }) - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { current: { model: 'gpt-live', effort: 'high' } } - }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-live', effort: 'high' } } + }) + ) expect(itemsOf(stream)).toEqual([]) // ── send ──────────────────────────────────────────────────────────────── @@ -698,12 +698,9 @@ describe('a structured codex session over agentSession.*', () => { expect(resumed.fence).toBe(fence + 1) expect(reaped.closed).toBe(true) expect(codex.live().resumedThreadId).toBe(THREAD) - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + expect(await readKnownOptions()).toMatchObject({ ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live', effort: 'high' } - } + result: { current: { effort: 'high' } } }) // The journal belongs to the session, not to the process that just died. expect(resumed.page.items.map(textOf)).toContain('Two files.') @@ -728,7 +725,7 @@ describe('a structured codex session over agentSession.*', () => { // The turn's lifecycle row outlives the turn: it is revised, never tombstoned. expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ 'message', - 'status', + 'turn', 'message', 'tool-call', 'approval', diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index ce452f7367a0..bf399d16ab42 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -48,7 +48,6 @@ async function setup() { provider: 'pi', agent: 'pi', providerHandle: undefined, - options: { unknown: 'unavailable-saved-option' }, accountHome: { variable: 'PI_CODING_AGENT_DIR', path: root }, location: { executionHostId: 'local', @@ -102,9 +101,9 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i script.releaseHandshake() await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) await vi.waitFor(() => - expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toEqual({ - unknown: 'unavailable-saved-option' - }) + expect( + peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options + ).toBeUndefined() ) await stopStructuredAgentSessionRuntime() expect(script.closes()).toBe(1) diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 949afdb89dc6..8fe711907688 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -44,6 +44,7 @@ const THREAD = 'thread-switch' const REVIEWER = 'thread-reviewer' const ROUTES: Record = { 'thread/start': { thread: { id: THREAD } }, + 'turn/start': { turn: { id: 'p2', status: 'inProgress' } }, 'model/list': { data: [ { @@ -132,7 +133,10 @@ describe('the chat strip and the session list read the same host child records', ...scriptedCodexTransport(openConnection), statusSink }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: 'gpt-test' } + }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) @@ -317,6 +321,13 @@ describe('the chat strip and the session list read the same host child records', body } ) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.handedOverAt + ).toBeDefined() + ) await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) await notify('item/started', { threadId: THREAD, diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b776140c68df..6e9a2f147c93 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -188,7 +188,8 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { interrupt: async () => undefined, cancelAsyncMessage: async () => false, stopTask: async () => {}, - send: async () => { + send: async (_message, beforeDispatch) => { + await beforeDispatch?.() child.calls.push('send') }, close: async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 4e3ce97f6aa5..18d99fc98b92 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -203,7 +203,7 @@ function NativeChatSessionOptionPickersInner({ pickerRequest, focusComposer }: NativeChatSessionOptionPickersProps): React.JSX.Element | null { - const [pendingIds, setPendingIds] = useState>(() => new Set()) + const [pendingId, setPendingId] = useState(null) const modelMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const optionsMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const model = snapshot.find((descriptor) => descriptor.category === 'model') @@ -211,30 +211,18 @@ function NativeChatSessionOptionPickersInner({ if (!surface || !model) { return null } - const modelPending = pendingIds.has(model.id) const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id) ? (pickerRequest?.sequence ?? null) : null - const markPending = (id: string) => (pendingId: string | null) => { - setPendingIds((current) => { - const next = new Set(current) - if (pendingId === null) { - next.delete(id) - } else { - next.add(id) - } - return next - }) - } const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { - runSessionOptionSurfaceCall(descriptor.id, markPending(descriptor.id), () => + runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => surface.setOption(descriptor.id, value) ) } const invokeAction = (descriptor: SessionOptionDescriptor): void => { - runSessionOptionSurfaceCall(descriptor.id, markPending(descriptor.id), () => + runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => surface.invokeAction(descriptor.id) ) } @@ -246,7 +234,7 @@ function NativeChatSessionOptionPickersInner({ const modelTrigger = { label: nativeChatModelPillLabel(model), tooltipLabel: translate('components.native-chat.composer.model', 'Model'), - disabled: isWorking || modelPending, + disabled: isWorking || pendingId !== null, disabledReason: modelReason, dispatched: sessionOptionDispatchUnconfirmed(model) } @@ -264,7 +252,7 @@ function NativeChatSessionOptionPickersInner({ defaultOpen={modelPickerDefaultOpen} choices={model.kind.choices} currentValue={model.kind.currentValue} - readOnly={!model.settable || modelPending} + readOnly={!model.settable || pendingId !== null} readOnlyReason={modelReason} onSelect={(value) => setOption(model, value)} focusComposer={focusComposer} @@ -287,7 +275,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(model, value)} invokeAction={() => invokeAction(model)} notePick={modelMenuFocus.notePick} @@ -304,7 +292,7 @@ function NativeChatSessionOptionPickersInner({ Trigger={DropdownMenuTrigger} label={nativeChatOptionsPillLabel(options)} tooltipLabel={optionsTooltip} - disabled={isWorking || options.every((descriptor) => pendingIds.has(descriptor.id))} + disabled={isWorking || pendingId !== null} disabledReason={optionsReason} dispatched={options.some(sessionOptionDispatchUnconfirmed)} /> @@ -330,7 +318,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(descriptor, value)} invokeAction={() => invokeAction(descriptor)} notePick={optionsMenuFocus.notePick} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ed6ba234461..863bce1c3f51 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -37,7 +37,10 @@ import { structuredSessionNotices } from './native-chat-structured-session-notic import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices' import { useNativeChatComposerError } from './use-native-chat-composer-notice' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' +import { + useStructuredAgentSessionHostExecution, + useStructuredAgentSessionOptionStamp +} from './StructuredAgentSessionStatusBridge' import { useNativeChatRewindHost } from './use-native-chat-rewind-host' import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' @@ -70,6 +73,7 @@ export function NativeChatStructuredSession( const { sendThroughLaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) + const hostOptionStamp = useStructuredAgentSessionOptionStamp(props.sessionId, props.target) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -86,7 +90,8 @@ export function NativeChatStructuredSession( composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, hostStopping: hostExecution.stopping, - hostOptionStamp: hostExecution.optionStamp, + optionSelection: hostExecution.optionSelection, + hostOptionStamp, providerStarting: hostExecution.phase === 'starting', // A start's own verdict (a signed-out agent) exists only once that start is proven. providerRunning: hostExecution.phase === 'ready', diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index cd32e239fa02..49c6a1e817b8 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { @@ -94,7 +95,6 @@ export function useStructuredAgentSessionHostExecutionPhase( ) } -/** Whether the host says a person's Stop is still ending the work; an older host never does. */ export function useStructuredAgentSessionHostStopping( sessionId: string, target: RuntimeClientTarget @@ -108,20 +108,48 @@ export function useStructuredAgentSessionHostStopping( ) } -/** Startup phase, Stop and option stamp, each re-rendering the chat only on a change. */ +type HostExecutionSelection = { + phase: AgentSessionStatusSummary['hostExecutionPhase'] | null + stopping: boolean + optionSelection: AgentSessionStatusSummary['optionSelection'] +} + +const EMPTY_HOST_EXECUTION: HostExecutionSelection = { + phase: null, + stopping: false, + optionSelection: undefined +} + +/** Select only the host facts the chat renders. */ export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget -): { - phase: ReturnType - stopping: boolean - optionStamp: string -} { - return { - phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), - stopping: useStructuredAgentSessionHostStopping(sessionId, target), - optionStamp: useStructuredAgentSessionOptionStamp(sessionId, target) - } +): HostExecutionSelection { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + const snapshot = useMemo(() => { + let previous = EMPTY_HOST_EXECUTION + return (): HostExecutionSelection => { + const summary = feed.getSnapshot().get(sessionId) + const next = { + phase: summary?.hostExecutionPhase ?? null, + stopping: summary?.stopping === true, + optionSelection: structuralValuesEqual(previous.optionSelection, summary?.optionSelection) + ? previous.optionSelection + : summary?.optionSelection + } + if ( + previous.phase === next.phase && + previous.stopping === next.stopping && + previous.optionSelection === next.optionSelection + ) { + return previous + } + previous = next + return next + } + }, [feed, sessionId]) + return useSyncExternalStore(feed.subscribe, snapshot, () => EMPTY_HOST_EXECUTION) } /** Only the host's rewind recovery latch, so a chat re-renders when that changes, not on every status. */ 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 24ee3073779d..bcc8d864e26a 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 @@ -2,6 +2,7 @@ // chat's view reads. import type { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' +import type { AgentSessionExecutionOptions } from '../../../../shared/agent-session-execution-options' export type StructuredAgentSessionPendingSend = { clientMessageId: string @@ -10,6 +11,7 @@ export type StructuredAgentSessionPendingSend = { previewUris: readonly string[] queuedAt: number delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** * `sending`: on its way, its one request out or about to be; the chat takes no other send meanwhile. * `recorded`: the host holds it; an open chat draws it until the host's row or card arrives. 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 076ce0474442..8ef262785675 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 @@ -125,7 +125,8 @@ export async function attemptStructuredAgentSessionSend(args: { clientOperationId: entry.clientMessageId, expectedRuntimeFence: fence, body: entry.body, - ...(entry.delivery ? { delivery: entry.delivery } : {}) + ...(entry.delivery ? { delivery: entry.delivery } : {}), + ...(entry.executionOptions ? { executionOptions: entry.executionOptions } : {}) }) type SendAnswer = AgentSessionMutationResult let answer: StructuredAgentSessionSendAnswer 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 e54850bdecda..a21d195d8a49 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 @@ -2,6 +2,7 @@ // of that chat out until it settles. import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' +import type { AgentSessionExecutionOptions } from '../../../../shared/agent-session-execution-options' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -20,6 +21,7 @@ export type StructuredAgentSessionSendInput = { attachments?: readonly StructuredAgentSessionAttachment[] /** Asks the host to hold it as a card while the agent works; decided once, at the send. */ delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** The caller keeps the text if it comes back, instead of the chat's composer. */ callerKeepsText?: true /** Made while the chat read Stopping: drawn after that turn until the host records it. */ @@ -42,6 +44,7 @@ export function takeStructuredAgentSessionSendSlot( previewUris: attachments.map((attachment) => attachment.previewUri), queuedAt: input.now ?? Date.now(), ...(input.delivery ? { delivery: input.delivery } : {}), + ...(input.executionOptions ? { executionOptions: input.executionOptions } : {}), ...(input.callerKeepsText ? { callerKeepsText: true as const } : {}), ...(input.sentWhileStopping ? { sentWhileStopping: true as const } : {}), ...(attachments.some((attachment) => attachment.connectionId) diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 1722720a7b3f..f160b47b6fe5 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -128,13 +128,16 @@ export function useHostModelCatalogUpgrade(args: { if (catalog && newLaunch) { recordHostModelCatalogSnapshot(target, agent, worktree ?? '', catalog) } + // A new scoped default must refresh the view even when its live choices already won. updateOptionState((current) => current.record !== activeOptionRecordRef.current ? current : catalog - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch - }) + ? { + ...applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { + newLaunch + }) + } : settleStructuredAgentSessionBuiltinCatalog(current) ) } 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 b5fdd7bdf378..3ae528191476 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' @@ -8,13 +8,19 @@ import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, + structuredAgentSessionOptionCatalog, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' -import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { + cloneNativeChatSessionOptionRecord, + createNativeChatSessionOptionRecord +} from '../../../../shared/native-chat-session-option-state' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { createCoalescedPollRunner, type CoalescedPollRunner @@ -33,7 +39,7 @@ function firstFrameOptionState( return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded } -/** The picker's option state for one session: seeded, kept across its fences, re-read each turn. */ +/** The picker's catalog and selection for one conversation on its execution host. */ export function useStructuredAgentSessionOptionState(args: { agent: AgentType optionCatalog: AgentSessionOptionCatalog | null @@ -51,6 +57,9 @@ export function useStructuredAgentSessionOptionState(args: { /** The option values the host's status carries: a pick made on another device moves it. */ hostOptionStamp: string readsBeforeStart: boolean + hostSelection: Readonly> | undefined + launchSeedOptions: Readonly> | undefined + held: Readonly> turnId: string | null unloadedTurnRevisions: number | undefined }) { @@ -65,11 +74,15 @@ export function useStructuredAgentSessionOptionState(args: { providerStarting, providerVisible, readsBeforeStart, + hostSelection, + launchSeedOptions, + held, sessionId, target, turnId, worktree } = args + const selectionFromStatus = hostSelection !== undefined const [conversationSupport, setConversationSupport] = useState<{ sessionId: string commands: readonly AgentSessionConversationCommand[] @@ -91,14 +104,12 @@ export function useStructuredAgentSessionOptionState(args: { ...(worktree ? { worktree } : {}) }) ) - // Read only when a fence or session resets the state, so a new target alone resets nothing. const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel }) useEffect(() => { firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel } }, [newLaunch, seedsModel, target, worktree]) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -113,6 +124,11 @@ export function useStructuredAgentSessionOptionState(args: { const previous = optionStateRef.current const sameSession = optionIdentityRef.current === identity optionIdentityRef.current = identity + if (sameSession && selectionFromStatus) { + optionMutationGeneration.current += 1 + updateOptionState((current) => ({ ...current, pendingPick: null })) + return + } const launch = firstFrameLaunch.current const seeded = firstFrameOptionState(agent, launch.target, { newLaunch: launch.newLaunch, @@ -122,25 +138,25 @@ export function useStructuredAgentSessionOptionState(args: { // The chat's next runtime starts with the picks the host saved, which this view last showed, // and a starting agent is not read: keep them, and the account's list, until a read replaces them. const kept = - sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null + sameSession && + (previous.record.model !== undefined || + (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) ? { ...previous, record: cloneNativeChatSessionOptionRecord(previous.record), - pendingId: null + pendingPick: null } : seeded // The chat's own, which a restart for a new mode keeps: the picker never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 - pendingOptionRef.current = null - optionStateRef.current = next activeOptionRecordRef.current = next.record - setOptionState(next) - }, [agent, fence, identity]) + updateOptionState(() => next) + }, [agent, fence, identity, selectionFromStatus, updateOptionState]) const optionsReadRef = useRef(null) - // Refresh options each turn to confirm which model the provider actually selected, and once - // the provider starts: only then has the host read what it will run. + // The read also carries commands, goal, context, image and rewind support. A capable host's + // selection comes from status even when this metadata read returns an older model. useEffect(() => { if (!providerVisible || (providerStarting && !readsBeforeStart) || !optionCatalog) { return @@ -164,11 +180,19 @@ export function useStructuredAgentSessionOptionState(args: { rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, fence }) - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result) - : current - ) + updateOptionState((current) => { + if (current.record !== activeOptionRecordRef.current) { + return current + } + if (!selectionFromStatus) { + return applyStructuredAgentSessionOptions(current, optionCatalog, result) + } + if (result.modelsKnown === false) { + return current + } + const catalog = structuredAgentSessionOptionCatalog(optionCatalog, result) + return { ...current, catalog, catalogSource: 'live' } + }) } }) optionsReadRef.current = runner @@ -185,6 +209,7 @@ export function useStructuredAgentSessionOptionState(args: { providerStarting, providerVisible, readsBeforeStart, + selectionFromStatus, sessionId, target, turnId, @@ -204,13 +229,89 @@ export function useStructuredAgentSessionOptionState(args: { // An options answer made before the account's listing landed listed none of it. const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) + const configured = readHostModelCatalogSnapshot(target, agent, { + newLaunch, + worktree, + seedsModel + }) return { conversationSupport, rereadOptions, optionState, + optionView: useMemo(() => { + // Live rows supply choices; the latest scoped host answer supplies the launch default. + const scoped = + configured && optionCatalog + ? applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent), + optionCatalog, + configured, + { newLaunch } + ).catalog + : null + const defaultModel = scoped?.defaultModelIsCliDefault + ? scoped.models.find((model) => model.isDefault) + : undefined + const defaultEffort = defaultModel?.options.find((option) => option.id === 'effort')?.kind + const catalog = + optionState.catalog && selectionFromStatus + ? { + ...optionState.catalog, + defaultModelIsCliDefault: defaultModel ? (true as const) : undefined, + models: optionState.catalog.models.map((model) => ({ + ...model, + isDefault: model.id === defaultModel?.id, + options: model.options.map((option) => + model.id === defaultModel?.id && + option.id === 'effort' && + option.kind.type === 'select' && + defaultEffort?.type === 'select' && + defaultEffort.defaultIsCliDefault && + option.kind.choices.some((choice) => choice.value === defaultEffort.defaultValue) + ? { + ...option, + kind: { + ...option.kind, + defaultValue: defaultEffort.defaultValue, + defaultIsCliDefault: true as const + } + } + : option + ) + })) + } + : optionState.catalog + const selected = selectionFromStatus + ? commitStructuredAgentSessionOptionValues( + { ...optionState, catalog, record: createNativeChatSessionOptionRecord(agent) }, + hostSelection + ) + : optionState + const pending = optionIdentityRef.current === identity ? optionState.pendingPick : null + return structuredAgentSessionOptionView( + { + ...(selectionFromStatus && pending + ? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value }) + : selected), + pendingPick: pending + }, + selectionFromStatus ? undefined : launchSeedOptions, + held + ) + }, [ + agent, + configured, + identity, + newLaunch, + optionCatalog, + held, + hostSelection, + launchSeedOptions, + optionState, + selectionFromStatus + ]), optionStateRef, activeOptionRecordRef, - pendingOptionRef, optionMutationGeneration, updateOptionState } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts new file mode 100644 index 000000000000..3067588e624d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from 'react' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { + commitStructuredAgentSessionOptionValues, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' +import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' + +/** A menu pick returns the latest held launch value even before React renders it. */ +export function useStructuredAgentSessionOptionSurface(args: { + sessionId: string + optionView: StructuredAgentSessionOptionState + optionSnapshot: SessionOptionDescriptor[] + changeOption: ( + id: string, + value: string | boolean + ) => Promise> | null> +}): SessionOptionsSurface { + const { sessionId, optionView, optionSnapshot, changeOption } = args + const setOption = useCallback( + async (id: string, value: string | boolean) => { + const committed = await changeOption(id, value) + const held = getStructuredAgentSessionLaunchSelection(sessionId)?.held + const selected = committed + ? commitStructuredAgentSessionOptionValues( + { ...optionView, record: cloneNativeChatSessionOptionRecord(optionView.record) }, + committed + ) + : optionView + return { + snapshot: structuredAgentSessionOptionSnapshot( + structuredAgentSessionOptionView(selected, undefined, held ?? {}) + ) + } + }, + [changeOption, optionView, sessionId] + ) + return useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [setOption, optionSnapshot] + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx new file mode 100644 index 000000000000..2d1957bca36b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), summary: vi.fn(), settings: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: mocks.settings +})) +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + holdStructuredAgentSessionLaunchOption: vi.fn(), + getStructuredAgentSessionLaunchSelection: () => null +})) + +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const EFFORTS = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } +] +const MODELS = [ + { + id: 'gpt-one', + label: 'One', + isDefault: true, + defaultEffort: 'medium', + efforts: EFFORTS.filter((effort) => effort.value !== 'high') + }, + { id: 'gpt-two', label: 'Two', efforts: EFFORTS } +] +const PICK = { model: 'gpt-two', effort: 'high' } +const LOCAL = { kind: 'local' } as const + +function pill(snapshot: readonly SessionOptionDescriptor[]) { + const current = (id: string): string | null => { + const entry = snapshot.find((option) => option.id === id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null + } + return { model: current('model'), effort: current('effort') } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => (resolve = settle)) + return { promise, resolve } +} + +beforeEach(() => { + mocks.call.mockReset() + mocks.summary.mockReset() + mocks.settings.mockReset() + resetHostModelCatalogSnapshotsForTests() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? { + origin: 'live-session', + models: MODELS, + fetchedAt: 1, + listingNamesConfiguredModel: true + } + : { + models: MODELS, + current: { model: 'gpt-one', effort: 'medium' }, + conversationCommands: [{ name: '/help', description: 'Help' }], + rewind: { supported: false, reason: 'unsupported' } + } + ) +}) + +function renderSelection(options: { + capable: boolean + selection?: Readonly> +}) { + const frames: ReturnType[] = [] + const launch = { kind: 'new' as const, heldOptions: {}, seedOptions: PICK } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this proof makes no option mutation. + const mutate = vi.fn() as unknown as StructuredAgentSessionMutate + mocks.summary.mockImplementation(() => + options.capable ? { optionSelection: options.selection ?? PICK } : { status: 'idle' } + ) + const initialProps: { fence: number; turnId: string | null } = { fence: 1, turnId: null } + const view = renderHook( + (props: { fence: number; turnId: string | null }) => { + const current = useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: props.fence, + turnId: props.turnId, + unloadedTurnRevisions: undefined, + mutate, + launch + }) + frames.push(pill(current.optionSnapshot)) + return current + }, + { initialProps } + ) + return { frames, ...view } +} + +it('keeps the host selection through fence, turn, and stale metadata reads', async () => { + const view = renderSelection({ capable: true }) + await waitFor(() => expect(pill(view.result.current.optionSnapshot)).toEqual(PICK)) + const nextFrame = view.frames.length + view.rerender({ fence: 2, turnId: 'turn-1' }) + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.options', { + sessionId: 'session-1' + }) + ) + await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) + expect(view.frames.slice(nextFrame)).toEqual(expect.arrayContaining([PICK])) + expect(view.frames.slice(nextFrame).every((frame) => frame.model === PICK.model)).toBe(true) + expect(view.frames.slice(nextFrame).every((frame) => frame.effort === PICK.effort)).toBe(true) + view.unmount() +}) + +it('uses the existing options read when an older host omits the selection field', async () => { + const view = renderSelection({ capable: false }) + await waitFor(() => + expect(pill(view.result.current.optionSnapshot)).toEqual({ + model: 'gpt-one', + effort: 'medium' + }) + ) + view.unmount() +}) + +it('admits one pending pick, including after the picker re-renders', async () => { + mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) + const write = deferred<{ options: Record }>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. + const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate + const view = renderHook(() => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch: { kind: 'new', heldOptions: {} } + }) + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let first!: Promise + let second!: Promise + act(() => { + first = view.result.current.setStructuredOption('model', 'gpt-two') + second = view.result.current.setStructuredOption('model', 'gpt-two') + }) + await expect(second).resolves.toBe(false) + await act(async () => { + expect(await view.result.current.setStructuredOption('model', 'gpt-two')).toBe(false) + write.resolve({ options: { model: 'gpt-two' } }) + await first + }) + expect(mutate).toHaveBeenCalledOnce() + view.unmount() +}) + +it('fences a late pick when the same session moves to another host', async () => { + mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) + const oldWrite = deferred<{ options: Record }>() + const newWrite = deferred() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each promise is a setOption reply. + const mutate = vi + .fn() + .mockReturnValueOnce(oldWrite.promise) + .mockReturnValueOnce(newWrite.promise) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + const remote = { kind: 'environment', environmentId: 'server-1' } as const + const initialTarget: RuntimeClientTarget = LOCAL + const view = renderHook, RuntimeClientTarget>( + (target: RuntimeClientTarget) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }), + { initialProps: initialTarget } + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let first!: Promise + act(() => { + first = view.result.current.setStructuredOption('model', 'gpt-two') + }) + view.rerender(remote) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let second!: Promise + act(() => { + second = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(mutate).toHaveBeenCalledTimes(2) + await act(async () => { + oldWrite.resolve({ options: { model: 'gpt-two' } }) + await first + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + await act(async () => { + newWrite.resolve(null) + await second + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + view.unmount() +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index f86b60f488c2..6f2447a30e1a 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 @@ -498,27 +498,31 @@ describe('useStructuredAgentSessionOptions', () => { describe('before the provider starts', () => { it('keeps a picked effort visible after a model pick during startup', async () => { + let reads = 0 answer({ - options: async () => ({ - models: ['opus', 'sonnet'].map((id) => ({ - id, - label: id, - efforts: ['low', 'high'].map((value) => ({ value, label: value })) - })), - current: { model: 'opus' } - }) + options: async () => { + if (reads++ > 0) { + return new Promise(() => {}) + } + return { + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: ['low', 'high'].map((value) => ({ value, label: value })) + })), + current: { model: 'opus' } + } + } }) + const { mutate } = mutateWith(async (_description, _method, fields) => fields) const { result, unmount } = renderOptions( { ...REOPENED, agent: 'claude', providerStarting: true }, - mutateWith(async () => new Promise(() => {})).mutate + mutate ) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) - act(() => { - void result.current.setStructuredOption('effort', 'low') - }) - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - act(() => { - void result.current.setStructuredOption('model', 'sonnet') + await act(async () => { + await result.current.setStructuredOption('effort', 'low') + await result.current.setStructuredOption('model', 'sonnet') }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') 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 b826462c04a0..609c955523f5 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 @@ -6,7 +6,6 @@ import type { } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' -import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { applyStructuredAgentSessionOptions, @@ -17,18 +16,18 @@ import { structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' -import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' +import { useStructuredAgentSessionOptionSurface } from './use-structured-agent-session-option-surface' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import { - getStructuredAgentSessionLaunchSelection, holdStructuredAgentSessionLaunchOption, type StructuredLaunchOptionOutcome } from '@/lib/structured-agent-session-launch-options' @@ -45,6 +44,7 @@ export function useStructuredAgentSessionOptions(args: { providerStarting?: boolean providerRunning?: boolean hostOptionStamp?: string + optionSelection?: Readonly> fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -62,18 +62,18 @@ export function useStructuredAgentSessionOptions(args: { transportEnabled, turnId } = args - const providerStarting = args.providerStarting ?? false const launchSeedOptions = launch?.seedOptions const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. const acceptsPicks = !transportEnabled || fence !== null const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) - const identity = `${agent}:${sessionId}` + const identity = `${agent}:${sessionId}:${structuredAgentSessionHostKey(target)}` + const selectionFromStatus = args.optionSelection !== undefined const { optionState, + optionView, optionStateRef, activeOptionRecordRef, - pendingOptionRef, optionMutationGeneration, updateOptionState, rereadOptions, @@ -89,11 +89,14 @@ export function useStructuredAgentSessionOptions(args: { ...(launch?.worktree ? { worktree: launch.worktree } : {}), seedsModel: launchSeedOptions?.model !== undefined, providerVisible, - providerStarting, + providerStarting: args.providerStarting ?? false, hostOptionStamp: args.hostOptionStamp ?? '', // A new chat's host knows nothing of its model before the provider starts; a resumed one's // holds the model it ran, so only the former waits rather than show the host's guess. readsBeforeStart: launch?.kind !== 'new', + hostSelection: args.optionSelection, + launchSeedOptions, + held, turnId, unloadedTurnRevisions: args.unloadedTurnRevisions }) @@ -130,26 +133,23 @@ export function useStructuredAgentSessionOptions(args: { [agent, target] ) const sendStructuredOption = useCallback( - async (id: string, encoded: string): Promise => { - const currentState = optionStateRef.current - const targetRecord = currentState.record + async (id: string, encoded: string): Promise> | null> => { + const targetRecord = optionStateRef.current.record const mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration - // Record-only: shown at once, so the picker stays usable while the write waits behind a start. - const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (shownAtOnce) { + const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (shownAtOnce && !selectionFromStatus) { updateOptionState((current) => current.record === targetRecord ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) : current ) - } else { - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) } + updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } })) let result: AgentSessionOptionResult | null = null + let committed: Readonly> | null = null try { result = await mutate( 'agentSession.setOption', @@ -157,43 +157,53 @@ export function useStructuredAgentSessionOptions(args: { { key: id, value: encoded } ) if (result && isCurrent()) { - const committed = result.options ?? { [id]: encoded } + const acceptedValues = result.options ?? { [id]: encoded } + committed = acceptedValues updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) + current.record === targetRecord && !selectionFromStatus + ? commitStructuredAgentSessionOptionValues(current, acceptedValues) : current ) - // The launch seed names the model an effort-only pick was made under. - rememberOptionPicks( - structuredAgentSessionOptionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), - committed - ) + rememberOptionPicks(optionView, acceptedValues) + if (!selectionFromStatus) { + void callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId } + ) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord && optionCatalog + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => {}) + } } - return Boolean(result) + return committed } finally { - // A settled write, or a shown one that failed, reads back what the host now holds. - if (isCurrent() && (result || shownAtOnce)) { + if (shownAtOnce && !result && !selectionFromStatus && isCurrent()) { void callStructuredAgentSession( target, 'agentSession.options', { sessionId } ) .then((refreshed) => { - if (isCurrent()) { + if (isCurrent() && optionCatalog) { updateOptionState((latest) => - latest.record === targetRecord && optionCatalog - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest + applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) ) } }) .catch(() => {}) } if (isCurrent()) { - pendingOptionRef.current = null updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } + current.record === targetRecord && current.pendingPick?.id === id + ? { ...current, pendingPick: null } : current ) } @@ -201,14 +211,13 @@ export function useStructuredAgentSessionOptions(args: { }, [ activeOptionRecordRef, - launchSeedOptions, mutate, optionCatalog, optionMutationGeneration, optionStateRef, - pendingOptionRef, - providerStarting, rememberOptionPicks, + optionView, + selectionFromStatus, sessionId, target, updateOptionState @@ -219,84 +228,62 @@ export function useStructuredAgentSessionOptions(args: { if (outcome.kind === 'refused') { toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) } else if (outcome.kind === 'accepted') { - rememberOptionPicks( - structuredAgentSessionOptionView( - optionStateRef.current, - launchSeedOptions, - NO_HELD_OPTIONS - ), - outcome.options - ) + rememberOptionPicks(optionView, outcome.options) } }, - [launchSeedOptions, optionStateRef, rememberOptionPicks] + [optionView, rememberOptionPicks] ) const optionSnapshot = useMemo(() => { - const snapshot = structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView(optionState, launchSeedOptions, held) - ) + const snapshot = structuredAgentSessionOptionSnapshot(optionView) return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot) - }, [acceptsPicks, held, launchSeedOptions, optionState]) - const setStructuredOption = useCallback( - async (id: string, value: string | boolean): Promise => { - const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held) - const pickState = { ...view, pendingId: providerStarting ? null : pendingOptionRef.current } + }, [acceptsPicks, optionView]) + const changeStructuredOption = useCallback( + async ( + id: string, + value: string | boolean + ): Promise> | null> => { const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(pickState, id, value) + !canSetStructuredAgentSessionOption( + { ...optionView, pendingPick: optionStateRef.current.pendingPick }, + id, + value + ) ) { - return false + return null } 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) - return applied !== null + return applied === null ? null : { [id]: encoded } } return sendStructuredOption(id, encoded) }, [ fence, - held, - launchSeedOptions, optionCatalog, + optionView, optionStateRef, - pendingOptionRef, - providerStarting, sendStructuredOption, sessionId, settleLaunchOptionPick, transportEnabled ] ) - const setOption = useCallback( - async (id: string, value: string | boolean) => { - await setStructuredOption(id, value) - // Read, not rendered: a pick the launch just took is not in this render's props yet. - const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held - return { - snapshot: structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView( - optionStateRef.current, - launchSeedOptions, - currentHeld ?? NO_HELD_OPTIONS - ) - ) - } - }, - [launchSeedOptions, optionStateRef, sessionId, setStructuredOption] - ) - const optionSurface = useMemo( - () => ({ - getSnapshot: () => optionSnapshot, - setOption, - invokeAction: async () => ({ snapshot: optionSnapshot }), - subscribe: () => () => {} - }), - [setOption, optionSnapshot] + const setStructuredOption = useCallback( + async (id: string, value: string | boolean): Promise => + (await changeStructuredOption(id, value)) !== null, + [changeStructuredOption] ) + const optionSurface = useStructuredAgentSessionOptionSurface({ + sessionId, + optionView, + optionSnapshot, + changeOption: changeStructuredOption + }) const support = transportEnabled && conversationSupport?.sessionId === sessionId ? conversationSupport : null diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx index 8ff5c03ae37b..4083682b3387 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx @@ -16,38 +16,9 @@ const QUEUEING = { capability: 'supported', enabled: true } as const afterEach(() => { resetStructuredAgentSessionSendsForTests() + mocks.call.mockReset() }) -// The composer moves the reader only for a message the transcript draws as a bubble. -it.each([ - ['a text follow-up while the agent works', QUEUEING, true, [], 'queued'], - ['the same follow-up when the agent is idle', QUEUEING, false, [], true], - ['with the queue setting off', { capability: 'supported', enabled: false }, true, [], true], - ['an image, which never queues', QUEUEING, true, [{ path: '/a.png', previewUri: '/a.png' }], true] -] as const)( - 'answers whether %s waits as a queued card', - (_case, queue, isWorking, images, admission) => { - mocks.call.mockImplementation(() => new Promise(() => {})) - const { result } = renderHook(() => - useStructuredAgentSessionSends({ - sessionId: 'session-1', - target: { kind: 'local' }, - fence: 1, - submissions: [], - queuedMessageIds: [], - queue, - historyLoaded: false, - isWorking - }) - ) - let sent: boolean | 'queued' = false - act(() => { - sent = result.current.send('follow up', images) - }) - expect(sent).toBe(admission) - } -) - it('takes no second send while the first is out', () => { mocks.call.mockImplementation(() => new Promise(() => {})) const { result } = renderHook(() => 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 911b4bff4083..2c24b7b77622 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,6 +1,8 @@ import { useCallback, useEffect, useSyncExternalStore } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { agentSessionExecutionOptionsFromSnapshot } from '../../../../shared/agent-session-execution-option-snapshot' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionQueueRequest, @@ -35,6 +37,7 @@ export function useStructuredAgentSessionSends(args: { isWorking?: boolean /** The chat reads Stopping: a send made now is drawn after the turn being stopped. */ stopping?: boolean + executionOptionSnapshot?: readonly SessionOptionDescriptor[] }) { const { fence, @@ -45,7 +48,8 @@ export function useStructuredAgentSessionSends(args: { sessionId, stopping, submissions, - target + target, + executionOptionSnapshot } = args const subscribe = useCallback( (listener: () => void) => subscribeToStructuredAgentSessionPendingSends(sessionId, listener), @@ -91,12 +95,16 @@ export function useStructuredAgentSessionSends(args: { return false } const delivery = structuredAgentSessionQueueRequest(queue, attachments) + const executionOptions = executionOptionSnapshot + ? agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) + : undefined const sent = sendStructuredAgentSessionMessage({ sessionId, target, text, attachments, ...(delivery ? { delivery } : {}), + ...(executionOptions ? { executionOptions } : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. @@ -112,7 +120,7 @@ export function useStructuredAgentSessionSends(args: { ? 'queued' : true }, - [isWorking, queue, queuedMessageIds, sessionId, stopping, target] + [executionOptionSnapshot, isWorking, queue, queuedMessageIds, sessionId, stopping, target] ) return { 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 d5cf7ff42200..f3812af5e8ba 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 @@ -11,6 +11,7 @@ import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured import { useStructuredAgentSessionHostQueuesCommands, useStructuredAgentSessionHostEditsQueuedMessages, + useStructuredAgentSessionHostMessageChoices, useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' @@ -55,6 +56,8 @@ export function useStructuredAgentSession(args: { providerStarting?: boolean /** The host runs the session's provider and its start is proven. */ providerRunning?: boolean + /** The current selection in a capable host's status, including an empty selection. */ + optionSelection?: Readonly> /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView /** The composer Edit copies a card's text into. */ @@ -116,6 +119,7 @@ export function useStructuredAgentSession(args: { isVisible, providerVisible, providerStarting, + optionSelection: args.optionSelection, ...(args.providerRunning ? { providerRunning: true } : {}), ...(args.hostOptionStamp ? { hostOptionStamp: args.hostOptionStamp } : {}), fence: state.fence, @@ -126,6 +130,7 @@ export function useStructuredAgentSession(args: { }) // Only a capable host may see `delivery`; a card any host publishes shows, with its actions. const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) + const messageChoices = useStructuredAgentSessionHostMessageChoices(target) const queuedMessageIds = useMemo( () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] @@ -158,7 +163,8 @@ export function useStructuredAgentSession(args: { queue, historyLoaded: transportEnabled && state.status === 'ready', isWorking: transportState.isWorking, - stopping: stopControl.stopping + stopping: stopControl.stopping, + executionOptionSnapshot: messageChoices ? optionSnapshot : undefined }) const threadGoal = useStructuredAgentSessionThreadGoal({ diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index e10d12804cfd..95dc0ff73c25 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' @@ -110,6 +111,13 @@ export function useStructuredAgentSessionHostQueuesMessages(target: RuntimeClien return useStructuredAgentSessionHostQueuesMessagesState(target) === 'supported' } +export function useStructuredAgentSessionHostMessageChoices(target: RuntimeClientTarget): boolean { + return useStructuredAgentSessionHostCapability( + target, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY + ) +} + /** Three-state: only `supported` lets a send ask to be queued; `unknown` holds nothing back. */ export function useStructuredAgentSessionHostQueuesMessagesState( target: RuntimeClientTarget diff --git a/src/shared/agent-session-execution-option-snapshot.test.ts b/src/shared/agent-session-execution-option-snapshot.test.ts new file mode 100644 index 000000000000..999c63d7d87c --- /dev/null +++ b/src/shared/agent-session-execution-option-snapshot.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionExecutionOptionsFromSnapshot } from './agent-session-execution-option-snapshot' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' +import { + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' + +describe('message choices while the picker loads', () => { + it.each(['codex', 'claude', 'pi'])('omits unknown %s choices', (agent) => { + const seed = structuredAgentSessionSeedCatalog(agent) + const state = createStructuredAgentSessionOptionState(agent, seed) + expect( + agentSessionExecutionOptionsFromSnapshot(structuredAgentSessionOptionSnapshot(state)) + ).toBeUndefined() + }) + + it.each([ + ['pi', 'anthropic/claude-sonnet-4-5'], + ['codex', 'some-unlisted-model'] + ])('omits %s choices until its saved model is listed', (agent, model) => { + const seed = structuredAgentSessionSeedCatalog(agent) + const state = commitStructuredAgentSessionOptionValues( + createStructuredAgentSessionOptionState(agent, seed), + { model, effort: 'high' } + ) + expect( + agentSessionExecutionOptionsFromSnapshot(structuredAgentSessionOptionSnapshot(state)) + ).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-execution-option-snapshot.ts b/src/shared/agent-session-execution-option-snapshot.ts new file mode 100644 index 000000000000..d0ffac39a05f --- /dev/null +++ b/src/shared/agent-session-execution-option-snapshot.ts @@ -0,0 +1,26 @@ +import type { SessionOptionDescriptor } from './native-chat-session-options' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' + +/** What the picker displayed at Send, excluding launch-only permission. */ +export function agentSessionExecutionOptionsFromSnapshot( + snapshot: readonly SessionOptionDescriptor[] +): AgentSessionExecutionOptions | undefined { + const choices = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] + const descriptors = snapshot.filter( + (descriptor) => descriptor.transport === 'agent-session' && choices.includes(descriptor.id) + ) + if ( + descriptors.length === 0 || + descriptors.some((descriptor) => descriptor.valueSource === 'unknown') + ) { + return undefined + } + const selected: Record = {} + for (const descriptor of descriptors) { + const value = descriptor.kind.currentValue + if (value !== undefined) { + selected[descriptor.id] = String(value) + } + } + return selected +} diff --git a/src/shared/agent-session-execution-options.ts b/src/shared/agent-session-execution-options.ts new file mode 100644 index 000000000000..38f27b02a058 --- /dev/null +++ b/src/shared/agent-session-execution-options.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +/** Choices fixed when a message is accepted. Permission remains a launch setting. */ +export const AgentSessionExecutionOptionsSchema = z + .object({ + model: z.string().min(1).max(256).optional(), + effort: z.string().min(1).max(64).optional(), + personality: z.string().min(1).max(64).optional(), + fastMode: z.enum(['true', 'false']).optional(), + serviceTier: z.string().min(1).max(64).optional() + }) + .strict() + +export type AgentSessionExecutionOptions = z.infer + +export function readAgentSessionExecutionOptions( + value: unknown +): AgentSessionExecutionOptions | null { + const parsed = AgentSessionExecutionOptionsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/agent-session-journal-submission-schema.ts b/src/shared/agent-session-journal-submission-schema.ts index ef65649badb7..c65ad4d6a1ac 100644 --- a/src/shared/agent-session-journal-submission-schema.ts +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -6,10 +6,12 @@ import { z } from 'zod' import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import type { AgentJournalSubmission } from './agent-session-journal-types' +import { AgentSessionExecutionOptionsSchema } from './agent-session-execution-options' // Every persisted field is listed, or a parse strips it: this schema drops unknown keys. export const AgentJournalSubmissionSchema = z.object({ clientMessageId: z.string().min(1), + executionOptions: AgentSessionExecutionOptionsSchema.optional(), fence: z.number().int(), payloadFingerprint: z.string(), dispatchState: z.string().min(1), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 2ff04f88ad4f..c4d021879970 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -19,6 +19,7 @@ import type { AgentSessionContextUsage } from './agent-session-context-usage' import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' import type { AgentMessageSource } from './agent-session-message-source' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' export { type AgentType } @@ -467,6 +468,8 @@ export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[n * the turn reads as delivery unconfirmed, never as sent and never as failed. */ export type AgentJournalSubmission = { clientMessageId: string + /** Immutable choices captured on acceptance; absent on older submissions. */ + executionOptions?: AgentSessionExecutionOptions /** Execution fence of the latest dispatch attempt or recovery. */ fence: number payloadFingerprint: string diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts new file mode 100644 index 000000000000..30ab778a4c59 --- /dev/null +++ b/src/shared/agent-session-options-result.ts @@ -0,0 +1,50 @@ +import type { AgentJournalCursor, AgentJournalThreadGoal } from './agent-session-journal-types' +import type { AgentSessionConversationCommand } from './agent-session-conversation-command' +import type { AgentSessionContextUsage } from './agent-session-context-usage' +import type { AgentSessionRewindSupport } from './agent-session-rewind' +import type { + AgentSessionFastModeState, + AgentSessionFastModeSupport, + AgentSessionModelOption +} from './agent-session-wire' + +/** 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[] + /** Present only where this session can change its goal, so a host without + * `agentSession.threadGoal` never offers the controls. `current` is the + * latest goal the whole journal records, for a client whose loaded page + * starts after it. */ + threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } + /** Present only where this session writes context facts to its turn rows. + * `current` is the newest of each part the whole journal records, for a + * client whose loaded page starts after the row that carries it. */ + contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor } + /** This session takes image attachments. Absent = unknown (an older host). */ + acceptsImages?: boolean + models: AgentSessionModelOption[] + /** False when no host catalog has loaded; models may contain built-in fallbacks. + * Absent keeps older hosts' authoritative options-list behavior. */ + modelsKnown?: boolean + /** Session/account/transport support. Absent means unknown, never unsupported. */ + fastModeSupport?: AgentSessionFastModeSupport + current: { + model?: string + effort?: string + /** Canonical preference for the next turn. Explicit false is meaningful. */ + fastMode?: boolean + /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ + serviceTier?: string + permissionMode?: string + /** Provider-reported effective routing, distinct from the next-turn preference. */ + fastModeState?: AgentSessionFastModeState + /** + * Option ids whose value the provider reported back, not merely accepted. + * Optional: a host that predates it sends nothing and the client keeps + * treating the value as unconfirmed, which is what it was before. + */ + confirmed?: readonly string[] + } +} diff --git a/src/shared/agent-session-queue-capabilities.ts b/src/shared/agent-session-queue-capabilities.ts new file mode 100644 index 000000000000..d521a43a2ea5 --- /dev/null +++ b/src/shared/agent-session-queue-capabilities.ts @@ -0,0 +1,17 @@ +// Strict Send params and result arms make the queue opt-in on paired clients. The host can still +// publish cards kept across restart or close without this gate. Mid-turn queueing stays dark until +// its steer rules ship; cards expose their draft-to-submission link and pause in the publication. +export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = + 'agent-session.queued-messages.v1' as const + +/** Strict Send params require a capability before clients include message choices. */ +export const AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY = + 'agent-session.message-choices.v1' as const + +// Kept cards may be edited without mid-turn queueing, so editing has its own gate. +export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY = + 'agent-session.queued-message-edit.v1' as const + +// A waiting command needs the queue's card surface to show where it went. +export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = + 'agent-session.queued-commands.v1' as const diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index c2e9ddbbf633..48c9cd775016 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -3,6 +3,7 @@ import type { UnreadAgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const @@ -35,6 +36,7 @@ export type AgentSessionQueuedMessagesResumeResult = { * subscribe stream and on history pages. Text-only v1. */ export type AgentSessionQueuedMessage = { messageId: string + executionOptions?: AgentSessionExecutionOptions position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 9a8fed9c76c2..e4eda0a4a0fa 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -3,7 +3,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskState } from './agent-session-background-task-wire' -import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind' +import type { AgentSessionRewindReason } from './agent-session-rewind' import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { @@ -12,11 +12,10 @@ import type { AgentSessionQueuePublicationFields } from './agent-session-queued-message-wire' +export type { AgentSessionOptionsResult } from './agent-session-options-result' export * from './agent-session-wire-refusals' export * from './agent-session-queued-message-wire' export * from './agent-session-turn-completion-wire' -import type { AgentSessionConversationCommand } from './agent-session-conversation-command' -import type { AgentSessionContextUsage } from './agent-session-context-usage' // ─── Structured agent-session wire contract ───────────────────────────────── // The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider // adapters and clients against exactly these types, so everything here must be @@ -30,7 +29,6 @@ import type { AgentJournalResetReason, AgentJournalResolution, AgentJournalSubmission, - AgentJournalThreadGoal, AgentJournalTurnLifecycle } from './agent-session-journal-types' import type { AgentTurnOutcome } from './agent-turn-outcome' @@ -250,6 +248,8 @@ export type AgentSessionStatusSummary = { model?: string /** The chat's saved permission mode; a client re-reads options when it or `model` moves. */ permissionMode?: string + /** The host's saved option selection, including an empty selection. Absent on older hosts. */ + optionSelection?: Readonly> /** The tool the running turn is inside, else the last one it used. Absent unless `status` * is 'working'. */ toolName?: string @@ -478,42 +478,3 @@ export type AgentSessionThreadGoalChange = | { kind: 'clear' } export type AgentSessionThreadGoalResult = { change: AgentSessionThreadGoalChange['kind'] } - -/** 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[] - /** Present only where this session can change its goal, so a host without - * `agentSession.threadGoal` never offers the controls. `current` is the - * latest goal the whole journal records, for a client whose loaded page - * starts after it. */ - threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } - /** Present only where this session writes context facts to its turn rows. - * `current` is the newest of each part the whole journal records, for a - * client whose loaded page starts after the row that carries it. */ - contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor } - /** This session takes image attachments. Absent = unknown (an older host). */ - acceptsImages?: boolean - models: AgentSessionModelOption[] - /** Session/account/transport support. Absent means unknown, never unsupported. */ - fastModeSupport?: AgentSessionFastModeSupport - current: { - model?: string - effort?: string - /** Canonical preference for the next turn. Explicit false is meaningful. */ - fastMode?: boolean - /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ - serviceTier?: string - /** The permission mode a start runs now; absent from a host or agent with no picker. */ - permissionMode?: string - /** Provider-reported effective routing, distinct from the next-turn preference. */ - fastModeState?: AgentSessionFastModeState - /** - * Option ids whose value the provider reported back, not merely accepted. - * Optional: a host that predates it sends nothing and the client keeps - * treating the value as unconfirmed, which is what it was before. - */ - confirmed?: readonly string[] - } -} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 11e63dc58381..9bc2f64480ed 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -52,6 +52,8 @@ import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability' export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import * as AGENT_SESSION_QUEUE from './agent-session-queue-capabilities' +export * from './agent-session-queue-capabilities' import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' @@ -233,34 +235,6 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // holds there. export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = 'agent-session.send-answers-proof.v1' as const -// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and -// the client's queueing setting and chord, never the published draft list or its card actions: a -// host without it still publishes a message it kept unsent across a restart or a close, and both -// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES: -// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host. -// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and -// never compares a draft id with a submission id. It also publishes the queue's pause once, as -// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent -// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list -// names the card the queue sends next once nothing runs, null while anything holds it; absent (an -// older host) reads as null. A card carries a hold of its own when its conversion failed -// (`send_failed`) or the host kept it unsent (`kept`). The host mechanism lands first; the constant -// gates the rollout. -export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = - 'agent-session.queued-messages.v1' as const -// Why: a host advertising this edits a queued card in place (`agentSession.queuedMessageUpdate`) -// and holds it from automatic delivery while edited (`agentSession.queuedMessageEditHold`). Apart -// from queued-messages.v1: kept and agent-mail cards are editable with mid-turn queueing still off. -// A client offers Edit only when the host advertises it; there is no copy-and-delete fallback. -export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY = - 'agent-session.queued-message-edit.v1' as const -// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects -// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued -// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too: -// the card is the only place the waiting command shows. -export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = - 'agent-session.queued-commands.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = @@ -415,8 +389,9 @@ export const RUNTIME_CAPABILITIES = [ // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUE.AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUE.AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUE.AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY, ...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index d9dd55c7b441..020bcfab85ee 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -22,6 +22,7 @@ export { SessionId } from './structured-agent-session-identifiers' import { z } from 'zod' +import { AgentSessionExecutionOptionsSchema } from '../agent-session-execution-options' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionOptions } from '../agent-session-record' import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' @@ -135,6 +136,7 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), + executionOptions: AgentSessionExecutionOptionsSchema.optional(), body: SendBody }) .strict() diff --git a/src/shared/structured-agent-session-model-catalog-application.test.ts b/src/shared/structured-agent-session-model-catalog-application.test.ts deleted file mode 100644 index 7586fb22bb99..000000000000 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' -import type { AgentSessionModelCatalogResult } from './agent-session-wire' -import { - applyStructuredAgentSessionModelCatalog, - applyStructuredAgentSessionOptions, - createStructuredAgentSessionOptionState, - settleStructuredAgentSessionBuiltinCatalog, - structuredAgentSessionOptionSnapshot -} from './structured-agent-session-options' - -const SEED = getAgentSessionOptionCatalog('codex')! - -const HOST_CATALOG: AgentSessionModelCatalogResult = { - origin: 'live-session', - models: [ - { - id: 'gpt-hosted', - label: 'GPT Hosted', - isDefault: true, - defaultEffort: 'high', - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - } - ], - fetchedAt: 1_000 -} - -// HOST_CATALOG omits `listingNamesConfiguredModel`, as an older host's does. -const LAUNCH = { newLaunch: true } - -describe('structured option state from the host model catalog', () => { - it('renders the quiet placeholder before the host answers, never a built-in label', () => { - const state = createStructuredAgentSessionOptionState('codex', SEED) - expect(structuredAgentSessionOptionSnapshot(state)).toEqual([ - expect.objectContaining({ - id: 'model', - valueSource: 'unknown', - settable: false, - kind: { type: 'select', choices: [] } - }) - ]) - }) - - it('makes the built-in list pickable, naming nothing, once the host says it has none', () => { - const state = settleStructuredAgentSessionBuiltinCatalog( - createStructuredAgentSessionOptionState('codex', SEED) - ) - expect(state.catalogSource).toBe('builtin') - const model = structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'model')! - expect(model.kind.type === 'select' && model.kind.choices.length).toBeGreaterThan(0) - expect(model.settable).toBe(true) - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBeUndefined() - }) - - it('upgrades the seed with host models as a provisional, uncommitted default', () => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - HOST_CATALOG, - LAUNCH - ) - expect(state.catalogSource).toBe('host') - const snapshot = structuredAgentSessionOptionSnapshot(state) - const model = snapshot.find((descriptor) => descriptor.id === 'model')! - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-hosted') - // Provisional provenance: a listing default, never a reported value. - expect(model.valueSource).toBe('default') - }) - - it('lists host models but names no value for a session it did not launch', () => { - // A reopened session may run a model picked in it, not the listing's default. - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - HOST_CATALOG, - { newLaunch: false } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const model = snapshot.find((descriptor) => descriptor.id === 'model')! - expect(model.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ - 'gpt-hosted' - ]) - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBeUndefined() - const effort = snapshot.find((descriptor) => descriptor.id === 'effort') - expect(effort?.kind.type === 'select' ? effort.kind.currentValue : undefined).toBeUndefined() - }) - - it('follows the host on whether its listed default is what a new chat runs', () => { - const currentModel = (listingNamesConfiguredModel: boolean): unknown => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - { ...HOST_CATALOG, listingNamesConfiguredModel }, - LAUNCH - ) - const model = structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'model')! - return model.kind.type === 'select' ? model.kind.currentValue : null - } - expect(currentModel(true)).toBe('gpt-hosted') - // A workspace whose own config may pick another model: the host says so, over the seed. - expect(currentModel(false)).toBeUndefined() - }) - - it('names the default effort the listing states, and none it does not', () => { - const effortOf = (catalog: AgentSessionModelCatalogResult) => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - catalog, - LAUNCH - ) - return structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'effort')! - } - const stated = effortOf(HOST_CATALOG) - expect(stated.kind.type === 'select' ? stated.kind.currentValue : null).toBe('high') - expect(stated.valueSource).toBe('default') - const { defaultEffort: _stated, ...unstated } = HOST_CATALOG.models[0]! - const silent = effortOf({ ...HOST_CATALOG, models: [unstated] }) - expect(silent.kind.type === 'select' ? silent.kind.currentValue : null).toBeUndefined() - expect(silent.valueSource).toBe('unknown') - }) - - it('settles on the built-in list on an unknown or empty host answer, and keeps a host list', () => { - const seeded = createStructuredAgentSessionOptionState('codex', SEED) - for (const answer of [ - { origin: 'unknown' as const }, - { origin: 'probe' as const, models: [], fetchedAt: 1 } - ]) { - const settled = applyStructuredAgentSessionModelCatalog(seeded, SEED, answer, LAUNCH) - expect(settled.catalogSource).toBe('builtin') - expect(settled.catalog).toBe(seeded.catalog) - const hosted = applyStructuredAgentSessionModelCatalog(seeded, SEED, HOST_CATALOG, LAUNCH) - expect(applyStructuredAgentSessionModelCatalog(hosted, SEED, answer, LAUNCH)).toBe(hosted) - } - }) - - it('never downgrades a live catalog to a host one', () => { - const live = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - { - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - current: { model: 'gpt-live', confirmed: ['model'] } - } - ) - expect(live.catalogSource).toBe('live') - expect(applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, LAUNCH)).toBe(live) - const snapshot = structuredAgentSessionOptionSnapshot(live) - const model = snapshot.find((descriptor) => descriptor.id === 'model')! - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-live') - }) -}) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 15d6ec7800d0..ae826c011324 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -64,5 +64,5 @@ function modelOptionView( if (seeded) { view = commitStructuredAgentSessionOptionValues(view, seed) } - return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } + return { ...commitStructuredAgentSessionOptionValues(view, held), pendingPick: state.pendingPick } } diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 4c7627354ca7..881e7375957b 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -55,7 +55,7 @@ export type StructuredAgentSessionOptionState = { record: NativeChatSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null - pendingId: string | null + pendingPick: { id: string; value: string } | null } /** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows @@ -69,7 +69,7 @@ export function createStructuredAgentSessionOptionState( catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), permissionMode: null, - pendingId: null + pendingPick: null } } @@ -146,8 +146,11 @@ export function applyStructuredAgentSessionOptions( ) return { ...state, - catalog: structuredAgentSessionOptionCatalog(seed, result), - catalogSource: 'live', + catalog: + result.modelsKnown === false + ? state.catalog + : structuredAgentSessionOptionCatalog(seed, result), + catalogSource: result.modelsKnown === false ? state.catalogSource : 'live', permissionMode: result.current.permissionMode ?? null } } @@ -177,8 +180,11 @@ function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionO if (!state.catalog) { return [] } - // Until the host answers, the built-in list would paint a label its list may replace. - if (state.catalogSource === 'seed') { + // A built-in list can name a saved model it recognizes, never a guessed default. + if ( + state.catalogSource === 'seed' && + !state.catalog.models.some((model) => model.id === state.record.model?.value) + ) { return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } const snapshot = buildNativeChatSessionOptionSnapshot({ @@ -214,7 +220,8 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingId !== id && + (state.pendingPick === null || + (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && state.pendingPick.id === id)) && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || @@ -228,7 +235,7 @@ export function commitStructuredAgentSessionOption( value: string ): StructuredAgentSessionOptionState { if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { - return { ...state, permissionMode: value, pendingId: null } + return { ...state, permissionMode: value, pendingPick: null } } if (!state.catalog) { return state @@ -240,7 +247,7 @@ export function commitStructuredAgentSessionOption( ) const decoded = decodeStructuredAgentSessionOptionValue(id, value) if (decoded === null) { - return { ...state, pendingId: null } + return { ...state, pendingPick: null } } const effort = id === 'model' ? getTrackedSessionOption(state.record, effectiveModel, 'effort') : undefined @@ -249,7 +256,7 @@ export function commitStructuredAgentSessionOption( if (effort) { setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched') } - return { ...state, pendingId: null } + return { ...state, pendingPick: null } } export function commitStructuredAgentSessionOptionValues( diff --git a/src/shared/structured-agent-session-send-mutation.test.ts b/src/shared/structured-agent-session-send-mutation.test.ts index 353186e4da11..9a09a41e37e2 100644 --- a/src/shared/structured-agent-session-send-mutation.test.ts +++ b/src/shared/structured-agent-session-send-mutation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentMessageSource } from './agent-session-message-source' +import { AgentJournalSubmissionSchema } from './agent-session-journal-submission-schema' import { SendParams } from './rpc-contract/structured-agent-session-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { @@ -26,6 +27,44 @@ const FROM: AgentMessageSource = { } describe('send fingerprints never cover the sender', () => { + it('keeps message choices outside the operation and body fingerprints', () => { + const send = (executionOptions?: { model: string; effort: string }) => + structuredAgentSessionMessageSendMutation({ + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + body: BODY, + ...(executionOptions ? { executionOptions } : {}) + }) + const legacy = send() + const chosen = send({ model: 'model-a', effort: 'high' }) + expect(chosen.envelope.payloadFingerprint).toBe(legacy.envelope.payloadFingerprint) + expect(chosen.executionOptions).toEqual({ model: 'model-a', effort: 'high' }) + expect(SendParams.safeParse(legacy).success).toBe(true) + expect(SendParams.safeParse(chosen).success).toBe(true) + expect(SendParams.omit({ executionOptions: true }).safeParse(chosen).success).toBe(false) + }) + + it('lets an older submission reader drop the new optional choices', () => { + const submission = { + clientMessageId: 'message-1', + executionOptions: { model: 'model-a' }, + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + expect(AgentJournalSubmissionSchema.parse(submission).executionOptions).toEqual({ + model: 'model-a' + }) + expect( + AgentJournalSubmissionSchema.omit({ executionOptions: true }).parse(submission) + ).not.toHaveProperty('executionOptions') + }) + it('keeps the digest every stored submission and draft already holds', () => { // Pinned bytes: a stored fingerprint that changed would stop matching its provider echo. expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index aec8ad3988e1..d205eee1d1ed 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -1,5 +1,6 @@ import type { AgentJournalItemBody, AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' import type { z } from 'zod' import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' @@ -8,6 +9,7 @@ export type StructuredAgentSessionSendMutation = { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions } /** What every send fingerprint covers: the message without its sender, which never reaches the @@ -37,6 +39,7 @@ export function structuredAgentSessionMessageSendMutation(message: { expectedRuntimeFence: number body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions }): StructuredAgentSessionSendMutation { // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. const delivery = message.delivery ? { delivery: message.delivery } : {} @@ -52,7 +55,8 @@ export function structuredAgentSessionMessageSendMutation(message: { }) }, body: message.body, - ...delivery + ...delivery, + ...(message.executionOptions ? { executionOptions: message.executionOptions } : {}) } }