From c4471b10c0961ffeb67f72754299d5d4eaa4ec31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:48:01 -0700 Subject: [PATCH 01/31] Capture message choices per accepted send and preserve picker state --- .../scripts/vitest-sqlite-runtime-files.mjs | 3 + ...uctured-agent-session-host-support.test.ts | 5 + ...e-structured-agent-session-host-support.ts | 3 + .../mobile-structured-agent-session-rpc.ts | 6 +- .../mobile-structured-agent-session-send.ts | 5 + .../use-mobile-structured-agent-options.ts | 14 +- .../use-mobile-structured-agent-session.ts | 1 + ...use-mobile-structured-send-with-outcome.ts | 7 + .../codex/codex-structured-session-adapter.ts | 8 +- .../codex-structured-session-options.test.ts | 44 +- .../codex/codex-structured-session-options.ts | 19 + src/main/codex/codex-structured-turn-start.ts | 4 +- .../journal-queued-messages.ts | 2 + .../journal-row-builders.ts | 4 + .../journal-row-schema.ts | 2 + .../journal-store-contracts.ts | 2 + .../journal-submission-fold.ts | 3 + .../queued-message-schema.ts | 4 +- .../queued-message-stored-row.ts | 15 + .../queued-message-table.ts | 11 +- .../structured-agent-session-adapter.ts | 3 + .../structured-agent-session-delivery-loop.ts | 7 + ...uctured-agent-session-execution-options.ts | 40 ++ ...structured-agent-session-host-mutations.ts | 2 + ...ured-agent-session-message-choices.test.ts | 55 +++ ...tructured-agent-session-message-options.ts | 61 +++ ...structured-agent-session-mutation-plans.ts | 10 +- ...agent-session-options-cold-catalog.test.ts | 137 ++++++ .../structured-agent-session-options-read.ts | 6 +- ...ueued-message-rig-provider.test-fixture.ts | 4 +- ...session-queued-message-rig.test-fixture.ts | 11 +- ...tructured-agent-session-queued-messages.ts | 14 +- ...ructured-agent-session-queued-mutations.ts | 1 + ...ctured-agent-session-queued-publication.ts | 1 + .../structured-agent-session-queued-send.ts | 2 + .../structured-agent-session-send.test.ts | 65 ++- ...d-agent-session-status-summary-equality.ts | 2 + ...structured-agent-session-status-summary.ts | 1 + .../structured-agent-session-turns.ts | 55 ++- .../NativeChatStructuredSession.tsx | 2 +- .../StructuredAgentSessionStatusBridge.tsx | 52 ++- .../structured-agent-session-pending-sends.ts | 2 + .../structured-agent-session-send-attempt.ts | 3 +- .../structured-agent-session-send-slot.ts | 3 + .../use-host-model-catalog-upgrade.ts | 9 +- ...e-structured-agent-session-option-state.ts | 139 +++++- ...structured-agent-session-option-surface.ts | 53 +++ ...gent-session-options.cold-catalog.test.tsx | 109 +++++ ...session-options.current-selection.test.tsx | 408 ++++++++++++++++++ ...session-options.no-catalog-effort.test.tsx | 104 +++++ ...t-session-options.pending-restart.test.tsx | 98 +++++ ...nt-session-options.scoped-default.test.tsx | 148 +++++++ .../use-structured-agent-session-options.ts | 151 +++---- .../use-structured-agent-session-sends.ts | 11 +- .../use-structured-agent-session.ts | 8 +- ...tructured-agent-session-host-capability.ts | 8 + ...agent-session-execution-option-snapshot.ts | 23 + src/shared/agent-session-execution-options.ts | 21 + ...agent-session-journal-submission-schema.ts | 2 + src/shared/agent-session-journal-types.ts | 3 + src/shared/agent-session-options-result.ts | 50 +++ .../agent-session-queue-capabilities.ts | 17 + .../agent-session-queued-message-wire.ts | 2 + src/shared/agent-session-wire.ts | 47 +- src/shared/protocol-version.ts | 35 +- .../structured-agent-session-params.ts | 2 + ...-session-model-catalog-application.test.ts | 93 ++++ .../structured-agent-session-option-view.ts | 2 +- .../structured-agent-session-options.ts | 26 +- ...ctured-agent-session-send-mutation.test.ts | 39 ++ .../structured-agent-session-send-mutation.ts | 6 +- 71 files changed, 2076 insertions(+), 239 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx create mode 100644 src/shared/agent-session-execution-option-snapshot.ts create mode 100644 src/shared/agent-session-execution-options.ts create mode 100644 src/shared/agent-session-options-result.ts create mode 100644 src/shared/agent-session-queue-capabilities.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 2f31e6c35c00..9e12afc9edd3 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -116,6 +116,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', @@ -131,6 +132,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', @@ -161,6 +163,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-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/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 63a434f90b1c..dc08a9d31a45 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -68,7 +68,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 +105,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) @@ -182,7 +180,6 @@ export function useMobileStructuredAgentOptions(args: { const currentState = optionStateRef.current const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( - pendingOptionRef.current !== null || !client || !sessionId || !optionCatalog || @@ -204,10 +201,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 +261,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 +310,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.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 4b11ee4fd582..b8649d56310f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -185,6 +185,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..3482b1cfd9d4 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 @@ -147,6 +150,9 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), + ...(messageChoices + ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(controller.snapshot) } + : {}), deadline, clientMessageId: send.clientMessageId, onError: onSendError @@ -169,6 +175,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + messageChoices, sessionId, stateRef, stopping diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a8d250bb1c5f..8bbc046ce049 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,5 +1,6 @@ import * as codexRewind from './codex-structured-rewind' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -19,7 +20,8 @@ import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, readLiveCodexSessionOptions, - readCodexAcquisitionOptions + readCodexAcquisitionOptions, + resolvedCodexMessageOptions } from './codex-structured-session-options' import { CodexAcquisitionRegistry, @@ -227,6 +229,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId: string clientMessageId: string body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions fence: number requestedAt?: number beforeDispatch?: () => Promise @@ -235,7 +238,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session.dispatchPending = true try { await input.beforeDispatch?.() - return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + const turnOptions = await resolvedCodexMessageOptions(session, input.executionOptions) + 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..9d76d870d2f3 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -15,7 +15,8 @@ import { applyCodexStructuredSessionOption, readCodexStructuredSessionOptions, readLiveCodexSessionOptions, - restoredCodexSessionOptions + restoredCodexSessionOptions, + resolvedCodexMessageOptions } from './codex-structured-session-options' import { reportedCodexThreadOptions } from './codex-structured-service-tier' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' @@ -58,6 +59,47 @@ async function primePicker(session: CodexSession): Promise { } describe('structured Codex session options', () => { + it('uses each message’s choices on turn/start without changing the saved next pick', 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: 'saved-next', effort: 'medium' }) + }) + + it('refuses an unavailable message option before a protocol prompt', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( + 'does not support Fast mode' + ) + expect(request).not.toHaveBeenCalled() + }) + it.each([ ['model', 'gpt-next'], ['effort', 'high'], diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index e9118f111ac1..6a20a51f3470 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -1,6 +1,7 @@ import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { CodexAppServerConnection } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { isCodexTurnOptionKey } from './codex-structured-turn-start' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -187,6 +188,24 @@ export async function applyCodexStructuredSessionOption( } } +/** Validate one message against a separate option map, leaving the pick for the next send alone. */ +export async function resolvedCodexMessageOptions( + session: CodexSession, + choices: AgentSessionExecutionOptions | undefined +): Promise> | undefined> { + if (!choices) { + return undefined + } + const selected = { ...session, options: new Map(session.options) } + for (const key of ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const) { + const value = choices[key] + if (value !== undefined) { + await applyCodexStructuredSessionOption(selected, key, value) + } + } + return Object.fromEntries(selected.options) +} + /** * With no listing known, a pick is kept as the next turn's intent rather than refused: catalog * bookkeeping must not gate it. Codex judges the model and tier on `turn/start`. diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 52a653a68e3c..1728683b2114 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 @@ -140,7 +141,7 @@ export async function startCodexTurn( clientUserMessageId: input.clientMessageId, input: turnInputFor(input.body), // Codex omits a tier the model does not list. - ...Object.fromEntries(host.options) + ...(input.turnOptions ?? Object.fromEntries(host.options)) }, { timeoutMs: input.timeoutMs } ) @@ -160,6 +161,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-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 a6e3aa03d189..399402ea88c9 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 { AgentSessionJournalProviderHandle } 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 { 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/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-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5bbb022cfa9f..8e909cbdc7ea 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' @@ -303,6 +304,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string clientMessageId: string body: AgentJournalMessageItem + /** Immutable choices of this submission; adapters apply them only to this dispatch. */ + executionOptions?: AgentSessionExecutionOptions fence: number /** Host clock on the submission row this send came from; the origin the turn * it opens records as `requestedAt`. */ 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 2785e275da9d..2f53fa7ef76a 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 @@ -41,6 +41,7 @@ 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' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -280,6 +281,12 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } } + if ( + this.deps.record(sessionId)?.provider === 'codex' && + waitsForCodexTurnOptions(session.journal, next) + ) { + return this.stop(sessionId) + } await handOverSubmission( { sessionId, 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-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 8dc39a62d567..cb0cac03d7c8 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 @@ -8,6 +8,7 @@ // an operation only the provider can perform starts it before admission. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -65,6 +66,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 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..1c1a1148b364 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +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) +}) 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..d9b5c98b405f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -0,0 +1,61 @@ +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' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' + +const OPTION_ORDER = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const + +function sameOptions( + a: AgentSessionExecutionOptions | undefined, + b: AgentSessionExecutionOptions | undefined +): boolean { + 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 activeTurnId = journal.activeTurnId() + if (!activeTurnId) { + return false + } + const active = journal.submissions().find((submission) => { + if (submission.handedOverAt === undefined) { + return false + } + const item = journal.item(agentJournalSubmissionKey(submission.clientMessageId)) + return item?.turnScope?.kind === 'turn' && item.turnScope.turnItemId === activeTurnId + }) + return active ? !sameOptions(active.executionOptions, next.executionOptions) : true +} + +/** Only the provider's live selection changes here; the saved picker choice stays for future sends. */ +export async function applyMessageExecutionOptions( + adapter: Pick, + sessionId: string, + fence: number, + options: AgentSessionExecutionOptions | undefined +): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!options) { + return { ok: true } + } + for (const key of OPTION_ORDER) { + const value = options[key] + if (value === undefined) { + continue + } + try { + await adapter.setOption({ sessionId, fence, key, value }) + } catch (error) { + if (isAgentSessionOptionRejectedError(error)) { + return { ok: false, reason: error.message } + } + throw error + } + } + return { ok: 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 305aa6766e1c..097ef7121823 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,6 +6,7 @@ // others' current answer. 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, @@ -138,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 @@ -160,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) @@ -170,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) => { @@ -342,8 +345,7 @@ export function setOptionPlan(params: { 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. + // 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-options-cold-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts new file mode 100644 index 000000000000..a295677b8038 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredAgentSessionSeedCatalog } from '../../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../shared/structured-agent-session-options' +import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' +import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { PI_RPC_AGENT } from '../../pi/rpc-agent-definition' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from '../agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +const DEFINITIONS = [ + CODEX_STRUCTURED_AGENT, + acpStructuredAgentDefinition(acpLaunchSpecFor('grok')!), + PI_RPC_AGENT, + CLAUDE_STRUCTURED_AGENT +] +const MODELS = [ + { + id: 'configured-model', + label: 'Configured model', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } +] + +function coldChat(definition: (typeof DEFINITIONS)[number], starting: boolean) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are all the record fields the options read and catalog fingerprint access. + const record = { + provider: definition.agent, + accountHome: { variable: 'TEST_AGENT_HOME', path: '/test/account' }, + location: { executionHostId: 'local', wslDistro: null }, + options: {} + } as unknown as AgentSessionRecord + const listing = Promise.withResolvers() + const probe = vi.fn(() => listing.promise) + const modelCatalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + recordWorkspacePath: async () => '/test/folder', + workspaceMayOverrideDefaultModel: async () => false, + listingNamesConfiguredModel: new Set([definition.agent]), + probes: { [definition.agent]: probe } + }) + const resting = { + child: starting ? { phase: 'starting' } : null, + params: { provider: definition.agent }, + journal: { context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting path accesses only these context members; no provider process runs. + const context = { + deps: { + adapter: {}, + agents: { definition: () => definition, capabilities: () => null }, + store: { getRecord: () => record }, + modelCatalog + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + const read = (sessionId = 'first-chat') => + readStructuredAgentSessionOptions(context, sessionId, { readsWithoutModel: true }) + return { listing, probe, modelCatalog, read } +} + +describe.each(DEFINITIONS)('$agent first options read before its catalog loads', (definition) => { + it.each([false, true])('fills the picker in place with starting=%s', async (starting) => { + const chat = coldChat(definition, starting) + const agent = definition.agent + const seed = structuredAgentSessionSeedCatalog(agent) + let state = createStructuredAgentSessionOptionState(agent, seed) + const pending = await chat.modelCatalog.read({ agent, sessionId: 'first-chat' }) + expect(pending).toMatchObject({ origin: 'unknown', listingInProgress: true }) + const waiting = chat.modelCatalog.read({ agent, sessionId: 'first-chat', waitForListing: true }) + const options = await chat.read() + expect(options.current.model).toBeUndefined() + state = applyStructuredAgentSessionOptions(state, seed, options) + const beforeListing = structuredAgentSessionOptionSnapshot(state) + chat.listing.resolve({ origin: 'probe', models: MODELS }) + const loaded = await waiting + state = applyStructuredAgentSessionModelCatalog(state, seed, loaded, { newLaunch: true }) + expect(structuredAgentSessionOptionSnapshot(state)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: 'configured-model' }) + }), + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ currentValue: 'high' }) + }) + ]) + ) + expect(beforeListing).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) + ]) + ) + expect(chat.probe).toHaveBeenCalledOnce() + + const secondOptions = await chat.read('second-chat') + const second = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState(agent, seed), + seed, + secondOptions + ) + expect(secondOptions.current).toMatchObject({ model: 'configured-model', effort: 'high' }) + expect(structuredAgentSessionOptionSnapshot(second)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: 'configured-model' }) + }) + ]) + ) + expect(chat.probe).toHaveBeenCalledOnce() + chat.modelCatalog.stop() + }) +}) 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 765203b69564..b742ab045199 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 @@ -28,7 +28,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 } @@ -133,6 +136,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 } : {}), 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 41fc7807bcc4..9af72d823e5e 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 } @@ -281,6 +287,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-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-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-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 004a16a7cb8b..09ae3e55e7b6 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,14 @@ // 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 { applyMessageExecutionOptions } from './structured-agent-session-message-options' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -103,17 +110,28 @@ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number + requestedAt: number, + executionOptions: AgentSessionExecutionOptions | undefined ): Promise { try { return await ctx.adapter.dispatch({ sessionId: ctx.sessionId, clientMessageId, body, + ...(executionOptions ? { executionOptions } : {}), fence: ctx.fence, requestedAt }) } catch (error) { + 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) } } } @@ -133,6 +151,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' @@ -155,9 +174,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 ) @@ -219,6 +242,29 @@ export async function handOverSubmission( await handOverStructuredAgentSessionCommand(ctx, submission, body) return } + const codex = ctx.record()?.provider === 'codex' + const applied = codex + ? { ok: true as const } + : await applyMessageExecutionOptions( + ctx.adapter, + ctx.sessionId, + ctx.fence, + submission.executionOptions + ) + if (!applied.ok) { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: providerDiagnosticOf(new Error(applied.reason)) + }), + { surface: 'rejection' } + ), + fence: ctx.fence + }) + return + } // The message joins the turn running at handover, a steer, or opens its own. await ctx.journal.resolveDispatch({ clientMessageId, @@ -232,7 +278,8 @@ export async function handOverSubmission( ctx, clientMessageId, body, - structuredAgentSessionHandoverOrigin(ctx.journal, submission) + structuredAgentSessionHandoverOrigin(ctx.journal, submission), + codex ? submission.executionOptions : undefined ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ed6ba234461..7ce911de145c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -86,7 +86,7 @@ export function NativeChatStructuredSession( composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, hostStopping: hostExecution.stopping, - hostOptionStamp: hostExecution.optionStamp, + optionSelection: hostExecution.optionSelection, 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.cold-catalog.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx new file mode 100644 index 000000000000..168b574fb32f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx @@ -0,0 +1,109 @@ +// @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() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const LOCAL = { kind: 'local' } as const +const EMPTY_SELECTION = {} +const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } +const MODELS = [ + { + id: 'configured-model', + label: 'Configured model', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } +] +const LOADED: AgentSessionModelCatalogResult = { + origin: 'probe', + models: MODELS, + fetchedAt: 1, + listingNamesConfiguredModel: true +} + +function renderChat(agent: string, selectionFromStatus: boolean, sessionId = 'first-chat') { + const frames: string[][] = [] + const view = renderHook(() => { + const options = useStructuredAgentSessionOptions({ + agent, + sessionId, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + ...(selectionFromStatus ? { optionSelection: EMPTY_SELECTION } : {}), + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: async () => null, + launch: LAUNCH + }) + frames.push(options.optionSnapshot.map((option) => option.id)) + return options + }) + return { ...view, frames } +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +it.each(['codex', 'grok', 'pi', 'claude'])( + '%s keeps its picker when options answer before the first listing, with either selection source', + async (agent) => { + for (const selectionFromStatus of [false, true]) { + resetHostModelCatalogSnapshotsForTests() + const listing = Promise.withResolvers() + mocks.call.mockImplementation( + async (_target: unknown, method: string, params: { waitForListing?: boolean }) => + method === 'agentSession.modelCatalog' + ? params.waitForListing + ? listing.promise + : { origin: 'unknown', listingInProgress: true } + : { models: [], modelsKnown: false, current: {}, conversationCommands: ['clear'] } + ) + const view = renderChat(agent, selectionFromStatus) + await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) + expect(view.frames.every((frame) => frame.includes('model'))).toBe(true) + await act(async () => listing.resolve(LOADED)) + await waitFor(() => { + const snapshot = view.result.current.optionSnapshot + expect(snapshot.find((option) => option.id === 'model')?.kind).toMatchObject({ + currentValue: 'configured-model' + }) + expect(snapshot.find((option) => option.id === 'effort')?.kind).toMatchObject({ + currentValue: 'high' + }) + }) + view.unmount() + + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? LOADED + : { models: MODELS, current: { model: 'configured-model', effort: 'high' } } + ) + const second = renderChat(agent, selectionFromStatus, 'second-chat') + expect( + second.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind + ).toMatchObject({ currentValue: 'configured-model' }) + await waitFor(() => expect(second.result.current.rewind).toBeDefined()) + expect(second.frames.every((frame) => frame.includes('model'))).toBe(true) + second.unmount() + } + } +) 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..d1f48321e4a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -0,0 +1,408 @@ +// @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('retires an accepted pick on reply even when another host change arrived first', async () => { + let selection: Readonly> = { model: 'gpt-one' } + mocks.summary.mockImplementation(() => ({ optionSelection: selection })) + 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 launch = { kind: 'new' as const, heldOptions: {} } + const view = renderHook( + (sessionId: string) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }), + { initialProps: 'session-1' } + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let request!: Promise + act(() => { + request = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + selection = { model: 'gpt-two' } + view.rerender('session-1') + selection = { model: 'gpt-one' } + view.rerender('session-1') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + await act(async () => { + write.resolve({ options: { model: 'gpt-two' } }) + await request + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + 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('drops refused and previous-chat picks without overriding current host selection', async () => { + let selection: Readonly> = { model: 'gpt-one' } + mocks.summary.mockImplementation(() => ({ optionSelection: selection })) + const refused = deferred() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: null is a refused mutation reply. + const mutate = vi.fn(() => refused.promise) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + const view = renderHook( + (sessionId: string) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }), + { initialProps: 'session-1' } + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let request!: Promise + act(() => { + request = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + view.rerender('session-2') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + await act(async () => { + refused.resolve(null) + await request + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + view.rerender('session-1') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + selection = { model: 'gpt-two' } + view.rerender('session-2') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + view.unmount() +}) + +it('shows the value the host accepted when it normalizes a pick', async () => { + let selection: Readonly> = { model: 'gpt-one' } + mocks.summary.mockImplementation(() => ({ optionSelection: selection })) + const write = deferred<{ options: Record }>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this 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 request!: Promise<{ snapshot: readonly SessionOptionDescriptor[] }> + act(() => { + request = view.result.current.optionSurface.setOption('model', 'gpt-two') + }) + selection = { model: 'gpt-one' } + view.rerender() + let returned!: { snapshot: readonly SessionOptionDescriptor[] } + await act(async () => { + write.resolve({ options: { model: 'gpt-one' } }) + returned = await request + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + expect(pill(returned.snapshot).model).toBe('gpt-one') + expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-one', optionId: 'model', value: 'gpt-one' }] + }) + view.unmount() +}) + +it('binds an effort-only pick and its returned snapshot to the host-selected model', async () => { + mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-two', effort: 'low' } })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this hook reads. + const mutate = vi.fn(async () => ({ + options: { effort: 'high' } + })) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + 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 + }) + ) + await waitFor(() => + expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-two', effort: 'low' }) + ) + let returned: { snapshot: readonly SessionOptionDescriptor[] } | undefined + await act(async () => { + returned = await view.result.current.optionSurface.setOption('effort', 'high') + }) + expect(pill(returned?.snapshot ?? [])).toEqual({ model: 'gpt-two', effort: 'high' }) + expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-two', optionId: 'effort', value: 'high' }] + }) + 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.no-catalog-effort.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx new file mode 100644 index 000000000000..d2cefeab9804 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx @@ -0,0 +1,104 @@ +// @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() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const LOCAL = { kind: 'local' } as const +const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } + +function selection(snapshot: readonly SessionOptionDescriptor[]) { + const value = (id: string) => { + const option = snapshot.find((entry) => entry.id === id) + return option?.kind.type === 'select' ? option.kind.currentValue : undefined + } + return { model: value('model'), effort: value('effort') } +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +it.each([ + ['claude', 'opus'], + ['codex', 'gpt-5.5'] +])('%s keeps a saved effort through start before its catalog loads', async (agent, model) => { + for (const selectionFromStatus of [false, true]) { + resetHostModelCatalogSnapshotsForTests() + const saved = { model, effort: 'high' } + const listing = Promise.withResolvers() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? listing.promise + : { models: [], modelsKnown: false, current: saved, conversationCommands: ['clear'] } + ) + const frames: ReturnType[] = [] + const view = renderHook( + ({ fence, starting }: { fence: number; starting: boolean }) => { + const options = useStructuredAgentSessionOptions({ + agent, + sessionId: 'saved-chat', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + providerStarting: starting, + ...(selectionFromStatus ? { optionSelection: saved } : {}), + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: async () => null, + launch: LAUNCH + }) + frames.push(selection(options.optionSnapshot)) + return options + }, + { initialProps: { fence: 1, starting: false } } + ) + await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) + expect(selection(view.result.current.optionSnapshot)).toEqual(saved) + const beforeStart = frames.length + view.rerender({ fence: 2, starting: true }) + expect(selection(view.result.current.optionSnapshot)).toEqual(saved) + view.rerender({ fence: 2, starting: false }) + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + expect( + frames.slice(beforeStart).every((frame) => frame.model === model && frame.effort === 'high') + ).toBe(true) + await act(async () => + listing.resolve({ + origin: 'probe', + models: [ + { + id: model, + label: 'Host model', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + fetchedAt: 1 + }) + ) + await waitFor(() => + expect( + view.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind + ).toMatchObject({ choices: [{ value: model, label: 'Host model' }] }) + ) + expect(selection(view.result.current.optionSnapshot)).toEqual(saved) + view.unmount() + } +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx new file mode 100644 index 000000000000..31f5c7cc90e9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx @@ -0,0 +1,98 @@ +// @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(), 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 +})) + +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 LOCAL = { kind: 'local' } as const +const LAUNCH = { kind: 'new' as const, heldOptions: {} } +const SELECTION = { model: 'gpt-one' } +const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ id, label: id, efforts: [] })) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => (resolve = settle)) + return { promise, resolve } +} + +beforeEach(() => { + resetHostModelCatalogSnapshotsForTests() + mocks.settings.mockReset() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? { origin: 'live-session', models: MODELS, fetchedAt: 1 } + : { models: MODELS, current: {} } + ) +}) + +it.each([false, true])( + 'admits a later pick after restart when the earlier reply %s arrives', + async (settlesLate) => { + const abandoned = deferred<{ options: Record }>() + const latest = deferred() + const send = vi.fn().mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(latest.promise) + const mutate: StructuredAgentSessionMutate = send + const initialFence: number | null = 1 + const view = renderHook, number | null>( + (fence: number | null) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: SELECTION, + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch: LAUNCH + }), + { initialProps: initialFence } + ) + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + let first!: Promise + act(() => { + first = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(send).toHaveBeenCalledOnce() + view.rerender(null) + view.rerender(2) + let second!: Promise + act(() => { + second = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(send).toHaveBeenCalledTimes(2) + if (settlesLate) { + await act(async () => { + abandoned.resolve({ options: { model: 'gpt-two' } }) + expect(await first).toBe(false) + }) + expect(mocks.settings).not.toHaveBeenCalled() + await act(async () => { + expect(await view.result.current.setStructuredOption('model', 'gpt-one')).toBe(false) + }) + expect(send).toHaveBeenCalledTimes(2) + } + await act(async () => { + latest.resolve(null) + expect(await second).toBe(false) + }) + const model = view.result.current.optionSnapshot.find((option) => option.id === 'model') + expect(model?.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-one') + view.unmount() + } +) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx new file mode 100644 index 000000000000..bf6aa9e11597 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx @@ -0,0 +1,148 @@ +// @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() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' +import { + forgetHostModelCatalogSnapshots, + recordHostModelCatalogSnapshot, + 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 LOCAL = { kind: 'local' } as const +const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ + id, + label: id, + isDefault: false, + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] +})) +const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } +const EMPTY_SELECTION = {} +const NO_MUTATE: StructuredAgentSessionMutate = async () => null + +function catalog(defaultModel = 'gpt-one'): AgentSessionModelCatalogResult { + return { + origin: 'live-session', + models: MODELS.map((model) => ({ + ...model, + isDefault: model.id === defaultModel, + ...(model.id === defaultModel ? { defaultEffort: 'medium' } : {}) + })), + fetchedAt: 1, + listingNamesConfiguredModel: true + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => (resolve = settle)) + return { promise, resolve } +} + +function renderChat() { + return renderHook( + (fence: number) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: EMPTY_SELECTION, + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: NO_MUTATE, + launch: LAUNCH + }), + { initialProps: 1 } + ) +} + +function selected(view: ReturnType) { + return Object.fromEntries( + view.result.current.optionSnapshot.map((option) => [ + option.id, + option.kind.type === 'select' ? (option.kind.currentValue ?? null) : null + ]) + ) +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +it.each(['catalog-first', 'options-first'] as const)( + 'uses the scoped default with an empty host selection after %s replies', + async (order) => { + const listing = deferred() + const options = deferred() + const metadata: AgentSessionOptionsResult = { + models: MODELS, + current: { model: 'gpt-two', effort: 'high' }, + conversationCommands: ['clear'] + } + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.modelCatalog' ? listing.promise : options.promise + ) + const view = renderChat() + await act(async () => { + if (order === 'catalog-first') { + listing.resolve(catalog()) + } else { + options.resolve(metadata) + } + }) + await act(async () => { + listing.resolve(catalog()) + options.resolve(metadata) + }) + await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) + expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) + view.unmount() + } +) + +it.each(['folder config', 'account'] as const)( + 'refreshes an empty selection default after the %s changes', + async (change) => { + recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog()) + let defaultModel = 'gpt-one' + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? catalog(defaultModel) + : { models: MODELS, current: {} } + ) + const view = renderChat() + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) + defaultModel = 'gpt-two' + if (change === 'account') { + forgetHostModelCatalogSnapshots(LOCAL, ['codex']) + } + recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog(defaultModel)) + view.rerender(1) + expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) + view.rerender(2) + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) + view.unmount() + } +) 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 c2021a3008ff..1f089317b151 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 @@ -67,12 +67,13 @@ export function useStructuredAgentSessionOptions(args: { // 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, @@ -93,6 +94,9 @@ export function useStructuredAgentSessionOptions(args: { // 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 }) @@ -129,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 = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (shownAtOnce) { + 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', @@ -156,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 ) } @@ -200,13 +211,13 @@ export function useStructuredAgentSessionOptions(args: { }, [ activeOptionRecordRef, - launchSeedOptions, mutate, optionCatalog, optionMutationGeneration, optionStateRef, - pendingOptionRef, rememberOptionPicks, + optionView, + selectionFromStatus, sessionId, target, updateOptionState @@ -217,85 +228,61 @@ 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) + }, [acceptsPicks, optionView]) + const changeStructuredOption = useCallback( + async ( + id: string, + value: string | boolean + ): Promise> | null> => { const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(view, id, value) + !canSetStructuredAgentSessionOption(optionView, 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 } } - if (pendingOptionRef.current !== null) { - return false + if (optionStateRef.current.pendingPick !== null) { + return null } return sendStructuredOption(id, encoded) }, [ fence, - held, - launchSeedOptions, optionCatalog, + optionView, optionStateRef, - pendingOptionRef, 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.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index 911b4bff4083..04cc1eba9490 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), @@ -97,6 +101,9 @@ export function useStructuredAgentSessionSends(args: { text, attachments, ...(delivery ? { delivery } : {}), + ...(executionOptionSnapshot + ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) } + : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. @@ -112,7 +119,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.ts b/src/shared/agent-session-execution-option-snapshot.ts new file mode 100644 index 000000000000..1e80ee67151e --- /dev/null +++ b/src/shared/agent-session-execution-option-snapshot.ts @@ -0,0 +1,23 @@ +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 { + const selected: Record = {} + for (const descriptor of snapshot) { + if ( + descriptor.transport !== 'agent-session' || + descriptor.valueSource === 'unknown' || + !['model', 'effort', 'personality', 'fastMode', 'serviceTier'].includes(descriptor.id) + ) { + continue + } + 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 b71bdce37268..3d73559cd4c9 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -50,6 +50,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' @@ -235,34 +237,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 = @@ -417,8 +391,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 index 7586fb22bb99..ac395c67752b 100644 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ b/src/shared/structured-agent-session-model-catalog-application.test.ts @@ -152,4 +152,97 @@ describe('structured option state from the host model catalog', () => { const model = snapshot.find((descriptor) => descriptor.id === 'model')! expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-live') }) + + it('keeps a known catalog and default when an options read has no loaded catalog', () => { + const hosted = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('codex', SEED), + SEED, + HOST_CATALOG, + LAUNCH + ) + const unknown = applyStructuredAgentSessionOptions(hosted, SEED, { + models: [], + modelsKnown: false, + current: {} + }) + expect(unknown.catalog).toBe(hosted.catalog) + expect(unknown.catalogSource).toBe('host') + expect(structuredAgentSessionOptionSnapshot(unknown)).toEqual( + structuredAgentSessionOptionSnapshot(hosted) + ) + const picked = applyStructuredAgentSessionOptions(unknown, SEED, { + models: [], + modelsKnown: false, + current: { model: 'gpt-hosted', effort: 'medium' } + }) + expect(picked.catalog).toBe(hosted.catalog) + const effort = structuredAgentSessionOptionSnapshot(picked).find( + (option) => option.id === 'effort' + ) + expect(effort?.kind).toMatchObject({ currentValue: 'medium' }) + }) + + it.each([ + ['claude', 'opus'], + ['codex', 'gpt-5.5'] + ])( + '%s shows a saved effort before its catalog loads and accepts the later list', + (agent, model) => { + const seed = getAgentSessionOptionCatalog(agent)! + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState(agent, seed), + seed, + { models: [], modelsKnown: false, current: { model, effort: 'high' } } + ) + expect(state.catalog).toBe(seed) + expect(state.catalogSource).toBe('seed') + expect(structuredAgentSessionOptionSnapshot(state)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: model }) + }), + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ currentValue: 'high' }) + }) + ]) + ) + const loaded = applyStructuredAgentSessionModelCatalog( + state, + seed, + { ...HOST_CATALOG, models: [{ ...HOST_CATALOG.models[0]!, id: model }] }, + LAUNCH + ) + expect(loaded.catalogSource).toBe('host') + expect( + structuredAgentSessionOptionSnapshot(loaded).find((option) => option.id === 'effort')?.kind + ).toMatchObject({ currentValue: 'high' }) + expect(loaded.catalog?.models.map((row) => row.label)).toEqual(['GPT Hosted']) + } + ) + + it('keeps an unrecognized saved model quiet until the host lists it', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex', SEED), + SEED, + { models: [], modelsKnown: false, current: { model: 'gpt-hosted', effort: 'high' } } + ) + expect(structuredAgentSessionOptionSnapshot(state)).toEqual([ + expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) + ]) + const loaded = applyStructuredAgentSessionModelCatalog(state, SEED, HOST_CATALOG, LAUNCH) + expect(structuredAgentSessionOptionSnapshot(loaded)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: 'gpt-hosted' }) + }), + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ currentValue: 'high' }) + }) + ]) + ) + }) }) 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 5920e68ae173..e1c739c7bb79 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -54,7 +54,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 @@ -68,7 +68,7 @@ export function createStructuredAgentSessionOptionState( catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), permissionMode: null, - pendingId: null + pendingPick: null } } @@ -145,8 +145,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 } } @@ -176,8 +179,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({ @@ -213,7 +219,7 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingId === null && + state.pendingPick === null && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || @@ -227,7 +233,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 @@ -239,10 +245,10 @@ export function commitStructuredAgentSessionOption( ) const decoded = decodeStructuredAgentSessionOptionValue(id, value) if (decoded === null) { - return { ...state, pendingId: null } + return { ...state, pendingPick: null } } setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel) - 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 } : {}) } } From e24895d9194de19baa26bba6e3b4b24669a180d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:02:52 -0700 Subject: [PATCH 02/31] Apply accepted Claude message choices at prompt handover --- src/main/claude/claude-structured-dispatch.ts | 16 ++ .../claude-structured-message-options.test.ts | 175 ++++++++++++++++++ .../claude-structured-message-options.ts | 68 +++++++ .../claude-structured-session-adapter.ts | 24 ++- .../claude-structured-session-startup.ts | 8 + .../claude/claude-structured-session-state.ts | 2 + 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/main/claude/claude-structured-message-options.test.ts create mode 100644 src/main/claude/claude-structured-message-options.ts 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-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts new file mode 100644 index 000000000000..7366be3bb1cd --- /dev/null +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +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) + }) + + it('rejects a refused model before any prompt is handed to Claude', async () => { + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + set_model: () => { + throw new ClaudeControlRequestError('set_model', 'model unavailable') + } + } + }) + const adapter = await acquired(claude) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'refused', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'low' }, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'rejected', + rejection: { kind: 'providerRejected' } + }) + expect(claude.connections[0]!.sent).toHaveLength(0) + }) +}) 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..849026a3f328 --- /dev/null +++ b/src/main/claude/claude-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 { 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' + +/** 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 +): Promise { + const model = options.model ?? (await defaultModelForMessage(session, timeoutMs)) + if (model && readClaudeCurrentModel(session).id !== model) { + await setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs) + } + + const effort = options.effort ?? session.configuredDefaults?.effort + if ( + effort && + (session.options.get('effort') !== effort || !session.confirmedOptions.has('effort')) + ) { + await setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs) + 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') + } + + const fastMode = options.fastMode ?? 'false' + if ( + session.options.get('fastMode') !== fastMode && + (options.fastMode || session.options.has('fastMode')) + ) { + await setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs) + if (session.options.get('fastMode') !== fastMode) { + throw new AgentSessionOptionRejectedError(`Claude did not apply Fast mode ${fastMode}`) + } + } +} + +async function defaultModelForMessage( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + if (session.configuredDefaults?.model) { + return session.configuredDefaults.model + } + if (!session.options.has('model')) { + return undefined + } + const response = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + 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 +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index db7f06c5fbb5..384673af0171 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -12,6 +12,8 @@ import { releaseClaudeAcquisition } from './claude-structured-acquisition-releas import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' +import { applyClaudeMessageOptions } from './claude-structured-message-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -162,8 +164,26 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey) } - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { + const session = this.session(input.sessionId) + const messageOptions = input.executionOptions + return dispatchClaudeTurn( + session, + input, + messageOptions === undefined + ? input.beforeDispatch + : async () => { + await input.beforeDispatch?.() + try { + await applyClaudeMessageOptions(session, messageOptions, this.deps.requestTimeoutMs) + } catch (error) { + throw error instanceof AgentSessionOptionRejectedError + ? error + : new AgentSessionOptionRejectedError(error) + } + } + ) + } compact: NonNullable = (input) => dispatchClaudeCommand(this.session(input.sessionId), input.command) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index b8002eff615f..19e12c9804f8 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -23,6 +23,7 @@ import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, + readClaudeSettingsApplied, readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' @@ -140,6 +141,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 diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index f34c5db28628..228453adb69e 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -162,6 +162,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 From e0b92fc175ed7fdb9a30c89c31ad75d7f484a25f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:13:34 -0700 Subject: [PATCH 03/31] Apply captured message choices at provider dispatch --- src/main/acp/acp-message-execution-options.ts | 35 ++++++++ .../acp-structured-session-adapter.test.ts | 42 ++++++++++ .../acp/acp-structured-session-adapter.ts | 24 ++++-- ...e-structured-session-adapter-turns.test.ts | 22 +++++ ...tructured-agent-session-message-options.ts | 29 ------- .../structured-agent-session-turns.ts | 26 +----- src/main/pi/rpc-message-dispatch.ts | 84 +++++++++++++++++++ src/main/pi/rpc-session-adapter.test.ts | 34 ++++++++ src/main/pi/rpc-session-adapter.ts | 34 ++------ 9 files changed, 239 insertions(+), 91 deletions(-) create mode 100644 src/main/acp/acp-message-execution-options.ts create mode 100644 src/main/pi/rpc-message-dispatch.ts 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..b2998cea5fd1 --- /dev/null +++ b/src/main/acp/acp-message-execution-options.ts @@ -0,0 +1,35 @@ +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 { + for (const key of ['model', 'effort'] as const) { + const value = options?.[key] + if (value === undefined || 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-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 472eee34444c..47718f02e0d7 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -158,6 +158,48 @@ 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('does not send a prompt when the agent refuses a message’s model', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => + agent.fail(frame, -32602, 'Model unavailable') + ) + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'refused-model', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'grok-4.6' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/prompt')).toEqual([]) + }) + 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 0b61db21d14a..1f4caa8a9b2d 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,7 @@ 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 { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { @@ -162,14 +162,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,6 +179,17 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte // The child ended while its attachments were read: nothing left Orca. return this.rejected(session, 'providerExited') } + const optionsApplied = await applyAcpMessageOptions( + session, + input.executionOptions, + (key, value) => + this.setOption({ sessionId: input.sessionId, fence: input.fence, key, value }), + () => + this.children.sessions.get(input.sessionId) === session && session.journalClosed === null + ) + if (!optionsApplied) { + return this.rejected(session, 'providerExited') + } await input.beforeDispatch?.() session.turns.dispatch({ clientMessageId: input.clientMessageId, 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..a48497b84166 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,28 @@ 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 send = connection.send + connection.send = async (...args) => { + const controls = connection.calls.map((call) => call.subtype) + expect(controls.indexOf('set_model')).toBeGreaterThan(-1) + expect(controls.indexOf('set_model')).toBeLessThan(controls.indexOf('apply_flag_settings')) + return send(...args) + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'chosen-send', + body: USER_MESSAGE, + fence: 7, + executionOptions: { model: 'sonnet', effort: 'high' } + }) + ).resolves.toEqual({ state: 'admitted' }) + }) + 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/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 index d9b5c98b405f..1e797a840732 100644 --- 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 @@ -2,8 +2,6 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal 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' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' const OPTION_ORDER = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const @@ -32,30 +30,3 @@ export function waitsForCodexTurnOptions( }) return active ? !sameOptions(active.executionOptions, next.executionOptions) : true } - -/** Only the provider's live selection changes here; the saved picker choice stays for future sends. */ -export async function applyMessageExecutionOptions( - adapter: Pick, - sessionId: string, - fence: number, - options: AgentSessionExecutionOptions | undefined -): Promise<{ ok: true } | { ok: false; reason: string }> { - if (!options) { - return { ok: true } - } - for (const key of OPTION_ORDER) { - const value = options[key] - if (value === undefined) { - continue - } - try { - await adapter.setOption({ sessionId, fence, key, value }) - } catch (error) { - if (isAgentSessionOptionRejectedError(error)) { - return { ok: false, reason: error.message } - } - throw error - } - } - return { ok: true } -} 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 09ae3e55e7b6..d5ca5b187ca9 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 @@ -12,7 +12,6 @@ import { } from '../../../shared/agent-session-failure' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' -import { applyMessageExecutionOptions } from './structured-agent-session-message-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { agentSessionFailureWords, @@ -242,29 +241,6 @@ export async function handOverSubmission( await handOverStructuredAgentSessionCommand(ctx, submission, body) return } - const codex = ctx.record()?.provider === 'codex' - const applied = codex - ? { ok: true as const } - : await applyMessageExecutionOptions( - ctx.adapter, - ctx.sessionId, - ctx.fence, - submission.executionOptions - ) - if (!applied.ok) { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'rejected', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { - detail: providerDiagnosticOf(new Error(applied.reason)) - }), - { surface: 'rejection' } - ), - fence: ctx.fence - }) - return - } // The message joins the turn running at handover, a steer, or opens its own. await ctx.journal.resolveDispatch({ clientMessageId, @@ -279,7 +255,7 @@ export async function handOverSubmission( clientMessageId, body, structuredAgentSessionHandoverOrigin(ctx.journal, submission), - codex ? submission.executionOptions : undefined + submission.executionOptions ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts new file mode 100644 index 000000000000..1c35cc0b72ce --- /dev/null +++ b/src/main/pi/rpc-message-dispatch.ts @@ -0,0 +1,84 @@ +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 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' } + ) + } + } + for (const key of ['model', 'effort'] as const) { + const value = input.executionOptions?.[key] + if (value === undefined) { + continue + } + try { + await applyPiRpcSessionOption(session.connection, session.selected, key, value) + } catch (error) { + if (session.connection.closed) { + return exited() + } + if (error instanceof JsonlRpcResponseError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(error.message, 'person') + ) + } + throw error + } + } + 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 e401cbed2096..8cd553e870a8 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -199,6 +199,40 @@ 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('leaves the prompt unsent when Pi refuses a sent model', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_model' + ? Promise.reject(new JsonlRpcResponseError(command, 'Model unavailable')) + : undefined + await expect( + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'refused-choice', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(h.connection.sent).toEqual([]) + }) + 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..7d8985007e6f 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 ) } From 323f070dba5dd094634573fd31cdf2fa3e59c9f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:31:55 -0700 Subject: [PATCH 04/31] Bind native chat choices to accepted message dispatch --- src/main/acp/acp-message-execution-options.ts | 8 +- src/main/acp/acp-structured-handshake.ts | 6 +- src/main/acp/acp-structured-options.test.ts | 15 +- src/main/acp/acp-structured-options.ts | 49 +---- .../acp-structured-publish-at-spawn.test.ts | 22 ++- .../acp-structured-session-adapter.test.ts | 25 +++ .../claude-structured-message-options.ts | 3 +- .../claude-structured-session-startup.test.ts | 44 ++++- .../claude-structured-session-startup.ts | 12 -- .../claude/claude-structured-spawn-options.ts | 5 +- .../claude-structured-start-fast-mode.ts | 72 ------- ...-agent-session-acquisition-options.test.ts | 63 +----- .../structured-agent-session-acquisition.ts | 30 +-- .../structured-agent-session-agent-start.ts | 7 + .../structured-agent-session-attach-flow.ts | 1 + ...ured-agent-session-attach-orchestration.ts | 18 +- ...ed-agent-session-conversation-stop.test.ts | 10 +- ...structured-agent-session-host-mutations.ts | 33 +--- .../structured-agent-session-host-types.ts | 3 - .../structured-agent-session-host.test.ts | 20 +- ...ured-agent-session-message-choices.test.ts | 70 ++++++- ...structured-agent-session-mutation-plans.ts | 8 +- ...ed-agent-session-option-settlement.test.ts | 47 +---- ...uctured-agent-session-options-read.test.ts | 12 +- .../structured-agent-session-options-read.ts | 25 ++- ...red-agent-session-provider-started.test.ts | 6 +- ...ructured-agent-session-provider-started.ts | 184 ++---------------- ...ctured-agent-session-refusal-retry.test.ts | 58 +----- ...uctured-agent-session-repeat-press.test.ts | 6 +- ...agent-session-startup-attempt-host.test.ts | 156 ++------------- ...tructured-agent-session-startup-attempt.ts | 21 +- ...structured-agent-session-startup-intent.ts | 143 -------------- ...nt-session-startup-permission-mode.test.ts | 19 +- ...ctured-agent-session-startup-picks.test.ts | 55 ++---- .../structured-agent-session-turns-options.ts | 34 ---- .../structured-agent-session-turns.ts | 1 - src/main/pi/rpc-message-dispatch.ts | 13 +- src/main/pi/rpc-session-adapter.test.ts | 54 ++++- src/main/pi/rpc-session-startup.ts | 41 +--- src/main/pi/rpc-session.ts | 6 +- 40 files changed, 407 insertions(+), 998 deletions(-) delete mode 100644 src/main/claude/claude-structured-start-fast-mode.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index b2998cea5fd1..9cdf026c687d 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -11,8 +11,14 @@ export async function applyAcpMessageOptions( setOption: (key: 'model' | 'effort', value: string) => Promise, isCurrent: () => boolean ): Promise { + 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] + const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort) if (value === undefined || session.options.reported()[key] === value) { continue } diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 2679ba2f68fc..22555f06d1ef 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' @@ -103,8 +103,7 @@ 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 restoreSkipped: string[] = [] const link: AgentSessionProviderHandleLink = { linkId: deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), @@ -158,7 +157,6 @@ export async function initializeAcpStructuredSession( reportedOptions: options.read().current, restoreSkippedOptions: restoreSkipped, optionRevision, - // Its saved picks restored: what it runs now is what its start resolved. catalogListing: options.startListing() } } 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..662feed95f66 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -8,8 +8,6 @@ import type { 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 +62,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 +75,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. */ @@ -267,43 +272,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 2205b4dfe3fd..a97d1cb0fb7e 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 @@ -59,11 +59,11 @@ 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' }, restoreSkippedOptions: [], optionRevision: 7 }) - 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 } @@ -158,7 +158,7 @@ describe('ACP child published before its handshake', () => { expect(rig.child().exited).toBe(true) }) - it('reports saved picks the provider refused 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) => @@ -167,9 +167,21 @@ describe('ACP child published before its handshake', () => { }) await rig.acquire({ options: { model: 'retired-model' } }) expect(rig.started()).toMatchObject({ - restoreSkippedOptions: ['model'], + restoreSkippedOptions: [], reportedOptions: { model: 'grok-4.7', effort: 'high' } }) + 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 47718f02e0d7..2707cbc93447 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -180,6 +180,31 @@ describe('ACP structured session adapter: turns', () => { }) }) + it('restores the opened model and effort when the next message omits both choices', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'override', + body: hello, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'default', + body: hello, + executionOptions: {} + }) + expect(rig.sent('session/set_config_option')).toMatchObject([ + { params: { configId: 'model', value: 'grok-4.6' } }, + { params: { configId: 'reasoning_effort', value: 'low' } }, + { params: { configId: 'model', value: 'grok-4.7' } }, + { params: { configId: 'reasoning_effort', value: 'high' } } + ]) + }) + it('does not send a prompt when the agent refuses a message’s model', async () => { const rig = await openAcpAdapterRig({ script: (agent) => diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 849026a3f328..c33bd2e5e92b 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -38,7 +38,8 @@ export async function applyClaudeMessageOptions( const fastMode = options.fastMode ?? 'false' if ( - session.options.get('fastMode') !== fastMode && + (session.options.get('fastMode') !== fastMode || + (session.fastModeAtStart && !session.confirmedOptions.has('fastMode'))) && (options.fastMode || session.options.has('fastMode')) ) { await setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs) diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index c3f2ffbb0700..ffbd756b7930 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' @@ -61,6 +62,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 19e12c9804f8..9131138ad40c 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -32,10 +32,6 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - admitClaudeStartFastMode, - applyClaudeStartFastMode -} from './claude-structured-start-fast-mode' export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, @@ -267,7 +263,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. @@ -279,11 +274,4 @@ async function settleClaudeStartupSettings( ...claudeRetiredOptions(session), optionRevision }) - if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { - if (input.isCurrent()) { - input.report(event) - } - }) - } } diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index b86b4ebfba50..fe28be8f83c7 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -80,9 +80,8 @@ export function claudeStructuredSpawnOptions(input: { skipped.push('fastMode') } else { 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') 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 66cb2df04cb9..000000000000 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ /dev/null @@ -1,72 +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, ClaudeStartupReport } 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 it as main's refused restore did, from the record too; 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, - report: (event: ClaudeStartupReport) => void -): 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') - report({ type: 'options-skipped', options: { fastMode } }) - } - } -} 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 495b7c629989..774f87be548d 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 @@ -288,7 +288,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) @@ -355,13 +355,11 @@ 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 when the provider skips restoring it', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ @@ -393,10 +391,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 = { @@ -428,7 +426,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') }) @@ -468,51 +467,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', - optionRevision: () => 0, - 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'] @@ -531,11 +487,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') { @@ -563,7 +516,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 71e85ff801f9..168d41831162 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' @@ -77,7 +74,8 @@ export async function acquireOwner( ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - optionRevision: input.optionRevision + optionRevision: input.optionRevision, + ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ @@ -95,27 +93,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, @@ -130,8 +107,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-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 75f238b7dd5b..63f6aba1953e 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 @@ -78,6 +78,7 @@ export type AttachFlowInput = { onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void /** The conversation's option revision, which the child's reports are stamped with. */ optionRevision: () => number + 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 348d7f49321b..71f50ed697b0 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 @@ -14,11 +14,7 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - performAttach, - type AttachedOwner, - type AttachFlowInput -} from './structured-agent-session-attach-flow' +import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { endStructuredAgentSessionReleasedChild } from './structured-agent-session-attach-failure' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { @@ -53,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. */ @@ -210,6 +207,7 @@ async function runAttachUnderAbort( return startupAttempts.track(sessionId, startup) }, optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), + ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), @@ -260,7 +258,6 @@ async function runAttachUnderAbort( // An acquire reports its phase; a re-attach keeps what its child already proved. phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), - ...launchedOptionsOf(owner, attempt.startup, current), ...(permissionMode === undefined ? {} : { permissionMode }) } } @@ -311,15 +308,6 @@ async function runAttachUnderAbort( } } -function launchedOptionsOf( - owner: AttachedOwner, - startup: StructuredAgentSessionStartupAttempt | null, - current: StructuredAgentSessionProviderChild | null -): Pick { - const launchedOptions = owner === 'acquired' ? (startup?.options ?? {}) : current?.launchedOptions - return launchedOptions ? { launchedOptions } : {} -} - type AttachCandidate = { child: StructuredAgentSessionProviderChild sink: DeferredStructuredAgentSessionEventSink 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 9989ef6386f0..294faaa86d0d 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 @@ -30,7 +30,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' 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' import { THREAD_ID, adapterFor, @@ -76,7 +76,7 @@ beforeEach(async () => { log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: log.logger, store, adapter: { @@ -222,7 +222,7 @@ describe('a Stop that names no turn', () => { ) expect(await pickOption('model', 'gpt-next')).toMatchObject({ ok: true, - value: { options: { model: 'gpt-next', serviceTier: 'priority' } } + value: { options: { model: 'gpt-next' } } }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( 1 @@ -230,9 +230,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-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index cb0cac03d7c8..e695ca8af89b 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 @@ -28,6 +28,7 @@ import { } from './structured-agent-session-mutation-context' import { openForProviderWrite, + openForWrite, sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' @@ -48,7 +49,6 @@ import { openWithReadyStructuredAgentSessionChild, runAfterRewindStart } from './structured-agent-session-ready-operation' -import { performSetOption } from './structured-agent-session-turns-options' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -187,44 +187,21 @@ export async function setStructuredAgentSessionOption( params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { const plan = setOptionPlan(params) - // With no child, or one still starting, the pick is intent: the next start replays it, and a - // starting child takes it before it is handed anything. Never a write the pick waits on. - const recordsIntent = () => { - const child = context.sessions.get(params.envelope.sessionId)?.child - return ( - params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !child || child.phase === 'starting' - ) - } return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - // Read as the call is admitted; a ready child's pick is still its owner's to make. - get conversationWrite() { - return recordsIntent() ? (true as const) : undefined - }, + conversationWrite: true, run: async (ctx) => { - if (recordsIntent()) { - if (params.key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { - // A report the starting child read before this pick is out of date. - context.optionRevisions.advance(params.envelope.sessionId) - } - return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - } - // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. - const wait = context.acquireAborts.begin(params.envelope.sessionId) - try { - return await performSetOption(ctx, params, wait.signal) - } finally { - wait.end() - // Whatever the write's outcome, the child may hold what it asked for now. + if (params.key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { context.optionRevisions.advance(params.envelope.sessionId) } + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) } }, - openForProviderWrite(context, params.envelope) + openForWrite(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index fd822f95086e..684d9b776f67 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -75,9 +75,6 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** The queued message whose delivery started this child, fixed when the start is made; absent * for any other start. In memory only: it tells a restart offer its own start from another. */ readonly startedFor?: string - /** The saved options its start launched with: a pick made while it started that differs is - * applied before it is accepted as ready. */ - readonly launchedOptions?: Readonly> /** The permission mode it was launched with, in memory only; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode close?: StructuredAgentSessionChildClose 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 155e6cbb1d00..30f955bd0cca 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 @@ -517,7 +517,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' } @@ -528,20 +528,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' } @@ -550,12 +550,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-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 1c1a1148b364..d5f0e18bfa80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -1,10 +1,16 @@ import { afterEach, beforeEach, expect, it } from 'vitest' +import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' let rig: QueuedMessageTestRig @@ -14,6 +20,68 @@ beforeEach(async () => { 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('queues a different choice during a running Codex turn', async () => { + await pickModel('model-a') + const first = await rig.workingSend() + await pickModel('model-b') + const second = rig.send('second') + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.dispatch).toHaveBeenCalledTimes(1) + await rig.settleAccepted(first, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions?.model)).toEqual([ + 'model-a', + '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, { 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 097ef7121823..f09d37fd5bc8 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 @@ -36,7 +36,6 @@ import { performCancel, performPrompt, performSend, - performSetOption, type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' @@ -344,7 +343,12 @@ export function setOptionPlan(params: { return { method: 'agentSession.setOption', fields: { key: params.key, value: params.value }, - run: (ctx) => performSetOption(ctx, params), + 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-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 8eba7f5bb605..a25de059ee78 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 @@ -10,7 +10,6 @@ import type { 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 { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -27,7 +26,6 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' -const PICKED_MODEL = 'gpt-picked' let root: string let store: AgentSessionRecordStore @@ -164,7 +162,7 @@ afterEach(async () => { }) describe('structured session options and close', () => { - it('publishes an acknowledged model without waiting for journal traffic', async () => { + it('keeps provider readback out of the saved next-message choice', async () => { const body = { kind: 'message' as const, role: 'user' as const, @@ -184,47 +182,10 @@ describe('structured session options and close', () => { expect(events).toEqual([ { type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', model: DEFAULT_MODEL })] + sessions: [expect.objectContaining({ status: 'idle', optionSelection: {} })] } ]) - const fields = { key: 'model', value: PICKED_MODEL } - - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - - expect(events.slice(1)).toEqual([ - { - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, model: PICKED_MODEL }) - } - ]) - }) - - it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { - optionFailure = new AgentSessionOptionRejectedError('model list unavailable') - const fields = { key: 'model', value: PICKED_MODEL } - const rejected = { - envelope: envelope('agentSession.setOption', fields), - ...fields - } - - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } - }) - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) // Closing a chat used to leave its provider child resident for the whole app session: the host's @@ -234,7 +195,7 @@ describe('structured session options and close', () => { await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, 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 30ae0f475cb7..517142195388 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 @@ -164,6 +164,7 @@ describe('live Codex option reads', () => { it('leaves the session lane free while a cold listing waits, then applies it under the child fence', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() + const record = { ...restingRecord(), options: { model: 'gpt-picked', effort: 'high' } } const child = { fence: 7 } const apply = vi.fn(() => ({ models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], @@ -183,7 +184,7 @@ describe('live Codex option reads', () => { deps: { adapter: { prepareReadOptions }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => restingRecord() } + store: { getRecord: () => record } }, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), @@ -191,7 +192,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 }) ) @@ -200,7 +203,10 @@ describe('live Codex option reads', () => { ) expect(apply).not.toHaveBeenCalled() listing.resolve() - await expect(reading).resolves.toMatchObject({ current: { model: 'gpt-live' } }) + await expect(reading).resolves.toMatchObject({ + models: [{ id: 'gpt-live' }], + current: { model: 'gpt-picked', effort: 'high' } + }) 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 b742ab045199..b2293f125ba1 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 @@ -240,9 +240,32 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null + 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, - ...(permissionMode ? { current: { ...options.current, permissionMode } } : {}), + current: { + ...selected.current, + ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), + ...(confirmed?.length ? { confirmed } : {}), + ...(permissionMode ? { permissionMode } : {}) + }, rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 9fbebde55158..e36758ff177c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -134,7 +134,7 @@ function lastPhase(): string | undefined { } describe('a publish-first Claude create whose init is slow', () => { - it('never persists the catalog default, and persists the reported model once started', async () => { + it('keeps provider defaults out of saved picks after starting', async () => { await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) // Published, not yet answering: the record holds no model rather than a guessed one. @@ -146,7 +146,7 @@ describe('a publish-first Claude create whose init is slow', () => { ) await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('ready') }) @@ -232,7 +232,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') ) await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(savedListings.at(-1)).toEqual({ sessionId: SESSION, listing: expect.objectContaining({ 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 1318096382ba..ab6a5ae44f74 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 @@ -3,20 +3,15 @@ // A starting acquisition hands the host an unproven child, so the record keeps only the // saved options the reservation carried, and the delivery loop hands it nothing. This is where the // host learns the start landed: the provider handle the child answered with is recorded (a resume -// needs it), picks made meanwhile are applied, a rewind left in doubt is settled, the child turns +// needs it), a rewind left in doubt is settled, the child turns // `ready`, its startup attempt ends, and the loop wakes to hand over what was queued. A handle that // cannot be recorded, or a rewind that stays in doubt, fails the start; a start whose limit passed // first is never accepted. -// Only once that handover is done is what the child reports persisted, in a step of its own, so -// bookkeeping never sits between a ready child and the user's first message; a failed write is -// reported, never thrown. Every report is persisted only if no pick or later report came after its -// read (`option-revisions`). +// Provider reports enrich the catalog and live facts; they never replace saved picks. // // These run under the session's own serialized steps, which its close and sends wait on, so the -// only provider calls here are the picks the user made while the child started and the rewind -// recovery, both bounded by the startup limit or the adapter's own request timeout. +// only provider call here is rewind recovery, bounded by the startup limit. -import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { StructuredAgentSessionOptionsReportedEvent, StructuredAgentSessionOptionsSkippedEvent, @@ -25,16 +20,13 @@ import type { import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' import { markProviderChildStarted, sameProviderChild, structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import { saveStructuredAgentSessionStartListing, saveStructuredAgentSessionStartReadbackListing @@ -42,10 +34,6 @@ import { import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' -import { - applyStructuredAgentSessionStartupIntent, - revertRefusedStartupIntent -} from './structured-agent-session-startup-intent' import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { @@ -57,10 +45,6 @@ export type StructuredAgentSessionProviderStartedContext = { runtimeState: { startupAttempts: Pick acquireAborts: Pick - optionRevisions: Pick< - StructuredAgentSessionOptionRevisions, - 'admitReport' | 'advance' | 'isNewest' - > } /** The barrier lifts: what was accepted while the child started is handed over now. Settles once * the loop has handed over all it can. */ @@ -69,16 +53,12 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } -type ReportedOptions = Omit - export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionStartedEvent ): Promise { - // On arrival, before any wait: reports are admitted in the order the child made them. - const admitted = admitReportedOptions(context, event) // Serialized behind the attach that published this child, so the lease it proved is committed. - const started = await context.serialize(event.sessionId, async () => { + await context.serialize(event.sessionId, async () => { const session = context.sessions.get(event.sessionId) const child = { generation: event.acquisitionGeneration, fence: event.fence } // A stale child's proof starts nothing: the barrier stays on the child the host holds. Nor @@ -95,14 +75,11 @@ export async function settleStructuredAgentSessionProviderStarted( context.stopStartingChild(event.sessionId, child) return null } - if (!(await applyPicksMadeWhileStarting(context, event, session.child))) { - return null - } if (!(await recoverRewindAtStart(context, event.sessionId, session))) { context.stopStartingChild(event.sessionId, child) return null } - // The limit can pass while the picks or the recovery ran; its stop ends this child next. + // The limit can pass while recovery ran; its stop ends this child next. if ( !context.runtimeState.startupAttempts.onClock(event.sessionId, child) || !markProviderChildStarted(session, child) @@ -110,51 +87,14 @@ export async function settleStructuredAgentSessionProviderStarted( return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) - const delivered = context.wakeDelivery(event.sessionId) + void context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { saveStructuredAgentSessionStartListing(context, event.sessionId, event.catalogListing) } context.publishStatus?.(event.sessionId) - return { delivered } + return undefined }) - if (!started) { - return - } - // Not awaited: the adapter's next event may be what the handover itself waits on. - void started.delivered.then(() => persistReportedOptions(context, event, admitted)) -} - -/** Picks made while the child started reach it before it is accepted, under its startup clock. - * False when a close, Stop, quit or the limit cut them short: whoever did ends the child, which - * never runs a value the chat does not show. A pick it refused is shown as what it runs. */ -async function applyPicksMadeWhileStarting( - context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent, - child: StructuredAgentSessionProviderChild -): Promise { - const launched = child.launchedOptions ?? {} - const applied = await applyStructuredAgentSessionStartupIntent( - { - deps: context.deps, - acquireAborts: context.runtimeState.acquireAborts, - optionRevisions: context.runtimeState.optionRevisions - }, - event.sessionId, - child, - launched - ) - if (applied.aborted) { - return false - } - await revertRefusedStartupIntent(context, { - sessionId: event.sessionId, - fence: child.fence, - refused: applied.failed, - launched, - reported: event.reportedOptions - }) - return true } /** A rewind left in doubt is settled by the first child that can read the provider's history: @@ -240,116 +180,16 @@ export async function settleStructuredAgentSessionOptionsReported( return } } - const admitted = admitReportedOptions(context, event) if (event.catalogListing) { void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) } - return persistReportedOptions(context, event, admitted) -} - -function admitReportedOptions( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions -): number | null { - return context.runtimeState.optionRevisions.admitReport(event.sessionId, event.optionRevision) -} - -/** Non-fatal, and only for the child that reported, while its report is still the newest word: a - * pick or a later report since it was admitted is newer than what it read. */ -function persistReportedOptions( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions, - admitted: number | null -): Promise { - if (admitted === null) { - return Promise.resolve() - } - return context - .serialize(event.sessionId, async () => { - const child = context.sessions.get(event.sessionId)?.child - const record = context.deps.store.getRecord(event.sessionId) - if ( - !child || - !sameProviderChild(child, { - generation: event.acquisitionGeneration, - fence: event.fence - }) || - !record || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) || - !context.runtimeState.optionRevisions.isNewest( - event.sessionId, - event.optionRevision, - admitted - ) - ) { - return - } - try { - await context.deps.store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options: nativeSessionOptionsFromReport({ - reported: event.reportedOptions, - restoreSkipped: event.restoreSkippedOptions, - ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), - ...(record.options ? { priorOptions: record.options } : {}) - }), - now: context.now() - }) - } finally { - context.publishStatus?.(event.sessionId) - } - }) - .catch((error: unknown) => { - context.deps.logger.warn('recording what a started provider reported failed', { - scope: 'provider-started-options', - sessionId: event.sessionId, - error - }) - }) + return undefined } -/** A running child showed saved options it cannot run: the record drops them, as a start that - * skipped them would, so the next start runs the provider's own. Reported, never thrown. */ +/** A rejected provider value is live protocol evidence; it does not change a later pick. */ export function settleStructuredAgentSessionOptionsSkipped( - context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionOptionsSkippedEvent + _context: StructuredAgentSessionProviderStartedContext, + _event: StructuredAgentSessionOptionsSkippedEvent ): Promise { - // A report read before the child showed this is out of date: it would write the value back. - context.runtimeState.optionRevisions.advance(event.sessionId) - return context.serialize(event.sessionId, async () => { - const { store } = context.deps - const record = store.getRecord(event.sessionId) - if ( - !record?.options || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) - ) { - return - } - const options = { ...record.options } - for (const [key, value] of Object.entries(event.options)) { - // A pick made since the child launched is the user's, whatever the child showed. - if (options[key] === value) { - delete options[key] - } - } - try { - await store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options, - now: context.now() - }) - } catch (error) { - context.deps.logger.warn('dropping a saved option the provider cannot run failed', { - scope: 'provider-options-skipped', - sessionId: event.sessionId, - error - }) - } finally { - context.publishStatus?.(event.sessionId) - } - }) + return Promise.resolve() } 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 744f933a10f2..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, @@ -30,7 +29,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' import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -96,7 +95,7 @@ async function createHarness(options: { attached?: boolean } = {}) { () => host ) const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, @@ -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 426e04c63936..158a0a360951 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 @@ -121,10 +121,12 @@ 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') }) it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index c453ac96d861..bdc7d8c1174c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -188,23 +188,6 @@ function startedEvent(generation = `generation-${acquire.mock.calls.length}`) { } } -function pick(value: string) { - const fields = { key: 'model', value } - return host.setOption(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: SESSION, - fields - }) - }, - ...fields - }) -} - /** The send's start has published its child, still starting; the host holds the send. */ async function heldBehindStart(text: string): Promise { const id = await accept(text) @@ -436,149 +419,38 @@ describe('a start that ends before it proves itself', () => { }) describe('what a started child reports', () => { - it('is persisted only after what was held is handed over', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const order: string[] = [] - dispatch.mockImplementationOnce(async (input) => { - order.push('dispatch') - return { - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: 1 - } - } - }) - const replace = store.replaceSessionOptions.bind(store) - vi.spyOn(store, 'replaceSessionOptions').mockImplementation(async (args) => { - order.push('persist') - return replace(args) - }) - - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) - - await eventually(() => expect(order).toEqual(['dispatch', 'persist'])) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'reported' }) - }) - - it('never overwrites a pick made after the child reported', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const report = { ...startedEvent(), reportedOptions: { model: 'reported' } } - dispatch.mockImplementationOnce(async () => { - // A pick lands while the first handover is still running. - void pick('picked') - return { state: 'admitted' as const } - }) - - await host.handleAdapterEvent(report) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' })) - await new Promise((resolve) => setTimeout(resolve, 50)) - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('never lets a settings read made before a pick undo it, even queued behind the pick', async () => { + it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) - // The settings read starts, then the user picks while it is out. - const readAt = optionRevisionNow() - let landPick = (): void => {} - setOption.mockImplementationOnce( - () => - new Promise((resolve) => { - landPick = () => resolve(undefined) - }) - ) - const picked = pick('picked') - await eventually(() => expect(setOption).toHaveBeenCalled()) - - // The read answers with what the child ran before the pick, while the pick is still in flight. - const reported = host.handleAdapterEvent({ - ...startedEvent(), - type: 'options-reported', - reportedOptions: { model: 'default' }, - optionRevision: readAt - }) - landPick() - expect(await picked).toMatchObject({ ok: true }) - await reported - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('never lands the start’s report after a newer settings report', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - let landHandover = (): void => {} - dispatch.mockImplementationOnce( - () => - new Promise((resolve) => { - landHandover = () => resolve({ state: 'admitted' as const }) - }) - ) - // The start's report waits on the handover; the settings report that follows it does not. - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'at-start' } }) - await eventually(() => expect(dispatch).toHaveBeenCalled()) - const reported = host.handleAdapterEvent({ + await host.handleAdapterEvent({ ...startedEvent(), type: 'options-reported', reportedOptions: { model: 'from-settings' } }) - - landHandover() - await reported - await new Promise((resolve) => setTimeout(resolve, 50)) await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) - it('keeps a settings read stamped before the host took the start’s report', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - // Both reads are stamped before the host takes either report, as a delivery that lags would. - const started = { ...startedEvent(), reportedOptions: { model: 'at-start' } } - const settings = { - ...started, - type: 'options-reported' as const, - reportedOptions: { model: 'from-settings' } - } - - await host.handleAdapterEvent(started) - await host.handleAdapterEvent(settings) - await new Promise((resolve) => setTimeout(resolve, 50)) - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) - }) - - it('takes a report read after a pick that failed', async () => { + it('preserves a later accepted pick when an older child reports options', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'picked' }, + now: NOW + }) + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) - setOption.mockRejectedValueOnce(new Error('the provider refused the model')) - await expect(pick('refused')).rejects.toThrow('the provider refused the model') - await host.handleAdapterEvent({ ...startedEvent(), type: 'options-reported', reportedOptions: { model: 'from-settings' } }) - - await eventually(() => - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) - ) + await host.flushStreamedEvents(SESSION) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) }) }) 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 8e19074321bf..b2c170e5a074 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 type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -31,8 +21,10 @@ export function mintStructuredAgentSessionStartupAttempt(input: { events?: StructuredAgentSessionEventSink signal?: AbortSignal optionRevision: () => number + startupOptions?: Readonly> }): StructuredAgentSessionStartupAttempt { const { record } = input + const options = input.startupOptions ?? record.options return { attemptId: randomUUID(), identity: input.identity, @@ -43,7 +35,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 } : {}), @@ -317,14 +309,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-startup-intent.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts deleted file mode 100644 index c530536f548c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Picks made while a child started are the conversation's intent, recorded at once and never a -// provider write the pick waits on. The child launched with the options saved when its start -// began; whatever the record holds now that differs is applied before the start is accepted, model -// before effort, since an effort is only valid for the model it is set on. A pick the child refuses -// is shown as what it runs; one cut short leaves the start unproven, so the child never runs it. - -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' -import type { - StructuredAgentSessionHostDeps, - StructuredAgentSessionProviderChildIdentity -} from './structured-agent-session-host-types' -import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' - -const APPLY_ORDER = ['model', 'effort'] - -function applyRank(key: string): number { - const rank = APPLY_ORDER.indexOf(key) - return rank === -1 ? APPLY_ORDER.length : rank -} - -/** Each saved value the child did not launch with, in the order a provider can take them. */ -export function structuredAgentSessionStartupIntentWrites( - launched: Readonly>, - saved: Readonly> | undefined -): [key: string, value: string][] { - return ( - Object.entries(saved ?? {}) - // Permission changes replace the child at delivery; providers read them only at launch. - .filter( - ([key, value]) => key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID && launched[key] !== value - ) - .sort(([a], [b]) => applyRank(a) - applyRank(b)) - ) -} - -/** What applying the intent came to: `aborted` when a close, Stop, quit or the startup limit cut - * it short, which leaves the start unproven; otherwise the keys the child refused. */ -export type StructuredAgentSessionStartupIntentOutcome = { - aborted: boolean - failed: string[] -} - -/** Applies the intent, each write under the start's own abort (the startup limit included). A write - * that fails is reported and its key returned, never blocking the start. */ -export async function applyStructuredAgentSessionStartupIntent( - context: { - deps: Pick - acquireAborts: Pick - optionRevisions: Pick - }, - sessionId: string, - child: StructuredAgentSessionProviderChildIdentity, - launched: Readonly> -): Promise { - const writes = structuredAgentSessionStartupIntentWrites( - launched, - context.deps.store.getRecord(sessionId)?.options - ) - const failed: string[] = [] - if (writes.length === 0) { - return { aborted: false, failed } - } - const wait = context.acquireAborts.begin(sessionId) - try { - for (const [key, value] of writes) { - if (wait.signal.aborted) { - break - } - try { - await context.deps.adapter.setOption({ - sessionId, - key, - value, - fence: child.fence, - signal: wait.signal - }) - } catch (error) { - failed.push(key) - context.deps.logger.warn('applying a pick made while the agent started failed', { - scope: 'startup-intent', - sessionId, - key, - error - }) - } finally { - // As a ready child's pick: whatever the write's outcome, a report read before it is stale. - context.optionRevisions.advance(sessionId) - } - } - return { aborted: wait.signal.aborted, failed } - } finally { - wait.end() - } -} - -/** A pick the child refused is shown as what the child runs, never left showing a value it does - * not: what it reported, else what it launched with. Bookkeeping: a failed write is reported. */ -export async function revertRefusedStartupIntent( - context: { - deps: Pick - now: () => number - }, - input: { - sessionId: string - fence: number - refused: readonly string[] - launched: Readonly> - reported: AgentSessionOptionsResult['current'] - } -): Promise { - const { store, logger } = context.deps - const record = store.getRecord(input.sessionId) - if (input.refused.length === 0 || !record) { - return - } - const running = nativeSessionOptionsFromReport({ reported: input.reported, restoreSkipped: [] }) - const options: Record = { ...record.options } - for (const key of input.refused) { - const value = running[key] ?? input.launched[key] - if (value === undefined) { - delete options[key] - } else { - options[key] = value - } - } - try { - await store.replaceSessionOptions({ - sessionId: input.sessionId, - fence: input.fence, - options, - now: context.now() - }) - } catch (error) { - logger.warn('showing what the agent runs after it refused a pick failed', { - scope: 'startup-intent', - sessionId: input.sessionId, - error - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts index bd8f9aeb6412..81d90583e9ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts @@ -51,11 +51,7 @@ it('keeps a permission pick while accepting the startup model report', async () optionRevision: 0 }) await vi.waitFor(() => - expect(rig.store.getRecord(SESSION)?.options).toEqual({ - model: 'provider-model', - effort: 'medium', - permissionMode: 'full-access' - }) + expect(rig.store.getRecord(SESSION)?.options).toEqual({ permissionMode: 'full-access' }) ) } finally { await rig.dispose() @@ -122,15 +118,17 @@ it.each([false, true])( }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ - ['model', 'picked-model'], - ['effort', 'high'] + ['model', 'launched-model'], + ['effort', 'low'] ]) const options = { model: 'picked-model', effort: 'high', permissionMode: 'full-access' } expect(rig.adapter.closeSession).toHaveBeenCalledTimes(hasOpenDispatch ? 0 : 1) expect(rig.adapter.acquire).toHaveBeenCalledTimes(hasOpenDispatch ? 1 : 2) expect(rig.adapter.acquire).toHaveBeenLastCalledWith( expect.objectContaining( - hasOpenDispatch ? { permissionMode: 'ask' } : { permissionMode: 'full-access', options } + hasOpenDispatch + ? { permissionMode: 'ask' } + : { permissionMode: 'full-access', options: { model: 'launched-model', effort: 'low' } } ) ) expect(rig.adapter.acquire.mock.invocationCallOrder[hasOpenDispatch ? 0 : 1]).toBeLessThan( @@ -144,7 +142,10 @@ it.each([false, true])( expect((await rig.host.send(CALLER, restTestSend('after restart', nextFence))).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) expect(rig.adapter.acquire).toHaveBeenLastCalledWith( - expect.objectContaining({ permissionMode: 'full-access', options }) + expect.objectContaining({ + permissionMode: 'full-access', + options: { model: 'picked-model', effort: 'high' } + }) ) } finally { await rig.dispose() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts index 1b3b2e1a5beb..e922234f1fdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts @@ -1,6 +1,4 @@ -// Picks made while an agent starts are applied before its start is accepted, under its startup -// clock. A pick the agent refuses is shown as what it runs; one cut short by the limit or a Stop -// leaves the start unproven, so no child ever runs a model the chat does not show. +// A pick made during startup belongs to the next message; the held message keeps its choice. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -156,48 +154,35 @@ function started(reportedModel = 'launched-model'): Promise { }) } -/** A write that never answers on its own: only its abort ends it. */ -function writeHeldUntilAborted(): void { - setOption.mockImplementation( - ({ signal }) => - new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - ) -} - async function dispatchState(clientMessageId: string) { return (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === clientMessageId )?.dispatchState } -it('shows what the agent runs when it refuses a pick made while it started', async () => { +it('saves a startup pick without writing it to the opening child', async () => { await pick('picked-model') expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') - setOption.mockRejectedValueOnce(new Error('model not available')) + expect(setOption).not.toHaveBeenCalled() await started() - expect(setOption).toHaveBeenCalledWith(expect.objectContaining({ value: 'picked-model' })) - expect(store.getRecord(SESSION)?.options?.model).toBe('launched-model') + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') expect(phases.at(-1)).toBe('ready') }) -it('never accepts a start whose limit passes while a pick is applying; the pick stays for the next start', async () => { +it('hands a held message over with its accepted choice after a later startup pick', async () => { const held = await sendHeld('held while starting') await pick('picked-model') - writeHeldUntilAborted() + expect(setOption).not.toHaveBeenCalled() - const proving = started() - await vi.waitFor(() => expect(setOption).toHaveBeenCalledOnce()) - await vi.advanceTimersByTimeAsync(SILENCE_MS) - await proving + await started() - await vi.waitFor(async () => expect(await dispatchState(held)).toBe('rejected')) - expect(closeSession).toHaveBeenCalled() - expect(phases).not.toContain('ready') - expect(dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(setOption).not.toHaveBeenCalled() + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ model: 'launched-model' }) + expect(await dispatchState(held)).toBe('unknown') expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') }) @@ -219,19 +204,3 @@ it('never accepts a start whose limit passed before its proof was settled', asyn expect(phases).not.toContain('ready') expect(dispatch).not.toHaveBeenCalled() }) - -it('ends a start a Stop cuts short while it applies a pick, never showing it ready', async () => { - await pick('picked-model') - writeHeldUntilAborted() - - const proving = started() - await vi.waitFor(() => expect(setOption).toHaveBeenCalledOnce()) - expect( - await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - ).toMatchObject({ ok: true }) - await proving - - await vi.waitFor(() => expect(closeSession).toHaveBeenCalled()) - expect(phases).not.toContain('ready') - expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') -}) 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 d5ca5b187ca9..8fab9ed810db 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 @@ -51,7 +51,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' diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index 1c35cc0b72ce..14aa093c0520 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -52,9 +52,18 @@ export async function dispatchPiRpcMessage( ) } } + const model = input.executionOptions?.model ?? session.initialOptions.model for (const key of ['model', 'effort'] as const) { - const value = input.executionOptions?.[key] - if (value === undefined) { + const value = + input.executionOptions?.[key] ?? + (key === 'model' || model === session.initialOptions.model + ? session.initialOptions[key] + : undefined) + if ( + value === undefined || + session.selected.get(key) === value || + (!session.selected.has(key) && session.initialOptions[key] === value) + ) { continue } try { diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 8cd553e870a8..3825749522ba 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -172,7 +172,8 @@ describe('Pi RPC session ownership and delivery', () => { origin: 'created', mintedAtFence: 7 }) - expect(h.started.restoreSkippedOptions).toEqual(['unknown', 'effort']) + expect(h.started.restoreSkippedOptions).toEqual([]) + expect(h.connection.requests).not.toContain('set_thinking_level') expect(h.connection.requests).toContain('get_commands') }) @@ -215,6 +216,57 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) }) + it('restores the opened model when a later snapshot omits its override', async () => { + const h = await setup() + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'override', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'default', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, + executionOptions: {} + }) + expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ + 'set_model', + 'set_model' + ]) + expect(h.connection.sent).toMatchObject([ + { type: 'prompt', message: 'first' }, + { type: 'prompt', message: 'second' } + ]) + }) + + it('applies the displayed default model and effort after different message choices', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + for (const [clientMessageId, model, effort] of [ + ['override', 'anthropic/model-2', 'low'], + ['default', 'anthropic/model-1', 'high'] + ]) { + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + executionOptions: { model, effort } + }) + } + expect(h.connection.requests.slice(-4)).toEqual([ + 'set_model', + 'set_thinking_level', + 'set_model', + 'set_thinking_level' + ]) + expect(h.connection.sent).toHaveLength(2) + }) + it('leaves the prompt unsent when Pi refuses a sent model', async () => { const h = await setup() h.connection.requestOverride = (command) => diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index af2d5f3a10b9..a1b2badef6c4 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' @@ -40,7 +34,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 +63,7 @@ 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 }) - } - } + const skipped: string[] = [] 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,7 +79,11 @@ export class PiRpcSessionStartup { this.options = options catalogListing = withLiveCatalogListing( options, - unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, picked, ...options }) + unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked: new Set(), + ...options + }) ).catalogListing } this.skipped = skipped diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 917b12eff7bd..2a93c34da9b6 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) => { @@ -159,6 +159,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 } From b564887d8c38bbd725b1e81a2099b594630f9c25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:33:46 -0700 Subject: [PATCH 05/31] Reset Codex turn choices for each captured message --- .../codex-structured-message-options.test.ts | 275 ++++++++++++++++++ .../codex/codex-structured-message-options.ts | 60 ++++ ...-structured-option-session.test-fixture.ts | 31 ++ .../codex/codex-structured-session-adapter.ts | 4 +- .../codex-structured-session-options.test.ts | 75 +---- .../codex/codex-structured-session-options.ts | 19 -- ...ured-agent-session-message-choices.test.ts | 43 ++- ...tructured-agent-session-message-options.ts | 3 + 8 files changed, 415 insertions(+), 95 deletions(-) create mode 100644 src/main/codex/codex-structured-message-options.test.ts create mode 100644 src/main/codex/codex-structured-message-options.ts create mode 100644 src/main/codex/codex-structured-option-session.test-fixture.ts 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..98cbc57c81f7 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -0,0 +1,275 @@ +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 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 without changing the saved next pick', 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: 'saved-next', effort: 'medium' }) + }) + + 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: 'high', + personality: 'friendly', + serviceTier: 'priority' + }) + }) + + it('resets to the configured model after opening the thread with an explicit model', async () => { + const codex = fakeCodex({ + 'thread/start': () => ({ + thread: { id: 'thread-abc' }, + model: 'model-a', + reasoningEffort: 'high' + }), + 'model/list': () => ({ + data: [ + { + model: 'model-a', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + defaultReasoningEffort: 'high' + }, + { + model: 'model-b', + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium' + } + ], + nextCursor: null + }), + 'config/read': () => ({ + config: { model: 'model-b', model_reasoning_effort: 'medium' } + }), + 'turn/start': () => ({ turn: { id: 'opened' } }) + }) + const adapter = adapterFor(codex, { model: 'model-a' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: { model: 'model-a', effort: 'high' } + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).toMatchObject({ + model: 'model-a' + }) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'a', + body: USER_MESSAGE, + executionOptions: { model: 'model-a', effort: 'high' }, + fence: 7 + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'b', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + expect( + codex.connections[0]?.calls + .filter((call) => call.method === 'turn/start') + .map((call) => call.params) + ).toMatchObject([ + { model: 'model-a', effort: 'high' }, + { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' } + ]) + }) + + 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('reads configured defaults when the scoped catalog has not been stored yet', async () => { + const request = vi.fn(async (method: string, _params?: Record) => { + if (method === 'model/list') { + return { + data: [ + { + model: 'model-b', + isDefault: false, + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium' + } + ], + nextCursor: null + } + } + return method === 'config/read' + ? { config: { model: 'model-b', model_reasoning_effort: 'medium' } } + : { turn: { id: 'default-turn' } } + }) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', effort: 'high' } + const turnOptions = await resolvedCodexMessageOptions(session, {}) + await startCodexTurn(session, { + clientMessageId: 'default', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'model/list', + 'config/read', + 'turn/start' + ]) + expect(request.mock.calls[2]?.[1]).toMatchObject({ + model: 'model-b', + effort: 'medium', + personality: 'none', + serviceTier: 'default' + }) + }) + + it('refuses an unavailable message option before a protocol prompt', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + seedMessageDefaults(session) + await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( + 'does not support Fast mode' + ) + expect(request).not.toHaveBeenCalled() + }) + + it('refuses an unlisted captured model before prompt input', async () => { + const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) + const session = optionSession(request) + seedMessageDefaults(session) + await expect( + resolvedCodexMessageOptions(session, { model: 'unlisted', effort: 'high' }) + ).rejects.toThrow('does not offer model unlisted') + expect(request).not.toHaveBeenCalled() + }) +}) 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..30b24156f6e8 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.ts @@ -0,0 +1,60 @@ +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, + codexSessionCatalogListingForPicker +} from './codex-structured-session-options' + +/** Validate one message without changing the saved pick for the next send. */ +export async function resolvedCodexMessageOptions( + session: CodexSession, + choices: AgentSessionExecutionOptions | undefined +): Promise> | undefined> { + if (choices === undefined) { + return undefined + } + let listing = codexAcquireCatalogListing(session.catalogAccess) + if (choices.model === undefined || choices.effort === undefined) { + try { + listing = await codexSessionCatalogListingForPicker(session, 3_000) + } catch (error) { + throw new AgentSessionOptionRejectedError(error) + } + } + const model = choices.model ?? listing?.models.find((entry) => entry.isDefault)?.id + if (!model) { + throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable')) + } + // Turn overrides persist in Codex, so every captured message starts from its own model defaults. + const selected = { + ...session, + options: new Map([ + ['personality', 'none'], + ['serviceTier', CODEX_DEFAULT_SERVICE_TIER] + ]), + reportedOptions: { ...session.reportedOptions, effort: undefined } + } + await applyCodexStructuredSessionOption(selected, 'model', model) + const defaultEffort = listing?.models.find((entry) => entry.id === model)?.defaultEffort + if (choices.effort === undefined && defaultEffort && !selected.options.has('effort')) { + 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) + } + } + const effort = selected.options.get('effort') + const personality = selected.options.get('personality') + const serviceTier = selected.options.get('serviceTier') + return { + model, + ...(effort ? { effort } : {}), + ...(personality ? { personality } : {}), + ...(serviceTier ? { serviceTier } : {}) + } +} 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-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 8bbc046ce049..27cc10206398 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -20,9 +20,9 @@ import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, readLiveCodexSessionOptions, - readCodexAcquisitionOptions, - resolvedCodexMessageOptions + readCodexAcquisitionOptions } from './codex-structured-session-options' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { CodexAcquisitionRegistry, requireLiveCodexSession, diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 9d76d870d2f3..a9bd52325e74 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -6,48 +6,18 @@ 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, readLiveCodexSessionOptions, - restoredCodexSessionOptions, - resolvedCodexMessageOptions + 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 = { @@ -59,47 +29,6 @@ async function primePicker(session: CodexSession): Promise { } describe('structured Codex session options', () => { - it('uses each message’s choices on turn/start without changing the saved next pick', 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: 'saved-next', effort: 'medium' }) - }) - - it('refuses an unavailable message option before a protocol prompt', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'turn-1' } - })) - const session = optionSession(request) - await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( - 'does not support Fast mode' - ) - expect(request).not.toHaveBeenCalled() - }) - it.each([ ['model', 'gpt-next'], ['effort', 'high'], diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 6a20a51f3470..e9118f111ac1 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -1,7 +1,6 @@ import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { CodexAppServerConnection } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' -import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { isCodexTurnOptionKey } from './codex-structured-turn-start' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -188,24 +187,6 @@ export async function applyCodexStructuredSessionOption( } } -/** Validate one message against a separate option map, leaving the pick for the next send alone. */ -export async function resolvedCodexMessageOptions( - session: CodexSession, - choices: AgentSessionExecutionOptions | undefined -): Promise> | undefined> { - if (!choices) { - return undefined - } - const selected = { ...session, options: new Map(session.options) } - for (const key of ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const) { - const value = choices[key] - if (value !== undefined) { - await applyCodexStructuredSessionOption(selected, key, value) - } - } - return Object.fromEntries(selected.options) -} - /** * With no listing known, a pick is kept as the next turn's intent rather than refused: catalog * bookkeeping must not gate it. Codex judges the model and tier on `turn/start`. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index d5f0e18bfa80..5669ea0ae03f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -1,5 +1,6 @@ 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, @@ -8,7 +9,8 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { HOST_TEST_SESSION as SESSION, - hostTestOperationId + hostTestOperationId, + HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' @@ -121,3 +123,42 @@ it('keeps a draft’s accepted choices through a host restart and Send now', asy 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 index 1e797a840732..96f6375effd7 100644 --- 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 @@ -9,6 +9,9 @@ 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]) } From d3fa3ff508efdde1e09314a8577c750c9f8813b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:19:46 -0700 Subject: [PATCH 06/31] Resolve Codex message defaults from native facts --- .../codex-structured-message-options.test.ts | 210 +++++++++++++++--- .../codex/codex-structured-message-options.ts | 64 +++--- .../codex/codex-structured-session-acquire.ts | 10 +- .../codex/codex-structured-session-start.ts | 8 + .../codex/codex-structured-session-state.ts | 2 + src/main/codex/codex-structured-turn-start.ts | 43 +++- ...nt-session-startup-permission-mode.test.ts | 9 +- 7 files changed, 273 insertions(+), 73 deletions(-) diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 98cbc57c81f7..57d87d9c6559 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' 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, @@ -50,7 +53,7 @@ function seedMessageDefaults(session: CodexSession): void { } describe('Codex captured message choices', () => { - it('uses each message’s choices on turn/start without changing the saved next pick', async () => { + 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' } })) @@ -77,7 +80,12 @@ describe('Codex captured message choices', () => { ['model-a', 'high'], ['model-b', 'low'] ]) - expect(Object.fromEntries(session.options)).toEqual({ model: 'saved-next', effort: 'medium' }) + 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 () => { @@ -125,13 +133,42 @@ describe('Codex captured message choices', () => { ]) expect(Object.fromEntries(session.options)).toEqual({ model: 'model-a', - effort: 'high', - personality: 'friendly', - serviceTier: 'priority' + effort: 'low', + personality: 'none', + serviceTier: 'default' }) }) it('resets to the configured model after opening the thread with an explicit model', async () => { + const store = new AgentModelCatalogStore() + const access = agentModelCatalogSessionAccess(store, CODEX_STRUCTURED_AGENT, '/test-account') + if (!access) { + throw new Error('expected Codex catalog access') + } + store.recordSuccess( + access.fingerprint, + 'codex', + { + origin: 'probe', + models: [ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + defaultEffort: 'high', + efforts: [{ value: 'high', label: 'High' }] + }, + { + id: 'model-b', + label: 'Model B', + isDefault: true, + defaultEffort: 'medium', + efforts: [{ value: 'medium', label: 'Medium' }] + } + ] + }, + 'discovery' + ) const codex = fakeCodex({ 'thread/start': () => ({ thread: { id: 'thread-abc' }, @@ -158,7 +195,9 @@ describe('Codex captured message choices', () => { }), 'turn/start': () => ({ turn: { id: 'opened' } }) }) - const adapter = adapterFor(codex, { model: 'model-a' }) + const adapter = adapterFor(codex, { model: 'model-a', codexHome: '/test-account' }, [], { + modelCatalog: store + }) await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, @@ -193,6 +232,60 @@ describe('Codex captured message choices', () => { { model: 'model-a', effort: 'high' }, { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' } ]) + expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('model/list') + expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('config/read') + }) + + 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('uses native defaults through a held catalog read and allows the child to close', async () => { + const codex = fakeCodex({ + 'model/list': () => new Promise(() => {}), + 'turn/start': () => ({ turn: { id: 'opened' } }) + }) + const adapter = adapterFor(codex, { model: 'newer-saved-model' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: {} + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'default', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).not.toHaveProperty('model') + expect( + codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params + ).not.toHaveProperty('model') + expect(await adapter.closeSession('session-1')).toBe(true) }) it('preserves the old host path when no message snapshot was supplied', async () => { @@ -211,44 +304,103 @@ describe('Codex captured message choices', () => { expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) }) - it('reads configured defaults when the scoped catalog has not been stored yet', async () => { + it('sends a fresh default while model discovery is held', async () => { const request = vi.fn(async (method: string, _params?: Record) => { if (method === 'model/list') { - return { - data: [ - { - model: 'model-b', - isDefault: false, - supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], - defaultReasoningEffort: 'medium' - } - ], - nextCursor: null - } + return new Promise(() => {}) } - return method === 'config/read' - ? { config: { model: 'model-b', model_reasoning_effort: 'medium' } } - : { turn: { id: 'default-turn' } } + return { turn: { id: 'default-turn' } } }) const session = optionSession(request) session.reportedOptions = { model: 'model-a', effort: 'high' } + session.nativeDefaults = { model: 'model-a', effort: 'high' } const turnOptions = await resolvedCodexMessageOptions(session, {}) await startCodexTurn(session, { clientMessageId: 'default', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, turnOptions }) - expect(request.mock.calls.map(([method]) => method)).toEqual([ - 'model/list', - 'config/read', - 'turn/start' - ]) - expect(request.mock.calls[2]?.[1]).toMatchObject({ - model: 'model-b', - effort: 'medium', + expect(request.mock.calls.map(([method]) => method)).toEqual(['turn/start']) + expect(request.mock.calls[0]?.[1]).toMatchObject({ personality: 'none', serviceTier: 'default' }) + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model') + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('effort') + }) + + it('restores opened native defaults after an explicit override without a catalog', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'opened' } + })) + const session = optionSession(request) + session.reportedOptions = { model: 'native-model', effort: 'medium' } + session.nativeDefaults = { model: 'native-model', effort: 'medium' } + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + await startCodexTurn(session, { + clientMessageId: 'override', + body, + turnOptions: await resolvedCodexMessageOptions(session, { + model: 'other-model', + effort: 'high' + }) + }) + await startCodexTurn(session, { + clientMessageId: 'reset', + body, + turnOptions: await resolvedCodexMessageOptions(session, {}) + }) + expect(request.mock.calls.map(([, params]) => params)).toMatchObject([ + { model: 'other-model', effort: 'high' }, + { model: 'native-model', effort: 'medium', personality: 'none', serviceTier: 'default' } + ]) + }) + + 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' }) }) it('refuses an unavailable message option before a protocol prompt', async () => { diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 30b24156f6e8..57b870e14040 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -3,12 +3,9 @@ import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wi 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, - codexSessionCatalogListingForPicker -} from './codex-structured-session-options' +import { applyCodexStructuredSessionOption } from './codex-structured-session-options' -/** Validate one message without changing the saved pick for the next send. */ +/** Validate one message against known facts without reading the provider or changing the next pick. */ export async function resolvedCodexMessageOptions( session: CodexSession, choices: AgentSessionExecutionOptions | undefined @@ -16,19 +13,26 @@ export async function resolvedCodexMessageOptions( if (choices === undefined) { return undefined } - let listing = codexAcquireCatalogListing(session.catalogAccess) - if (choices.model === undefined || choices.effort === undefined) { - try { - listing = await codexSessionCatalogListingForPicker(session, 3_000) - } catch (error) { - throw new AgentSessionOptionRejectedError(error) - } - } - const model = choices.model ?? listing?.models.find((entry) => entry.isDefault)?.id - if (!model) { + 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')) } - // Turn overrides persist in Codex, so every captured message starts from its own model defaults. + 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([ @@ -37,9 +41,21 @@ export async function resolvedCodexMessageOptions( ]), reportedOptions: { ...session.reportedOptions, effort: undefined } } - await applyCodexStructuredSessionOption(selected, 'model', model) - const defaultEffort = listing?.models.find((entry) => entry.id === model)?.defaultEffort - if (choices.effort === undefined && defaultEffort && !selected.options.has('effort')) { + 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) { @@ -48,13 +64,5 @@ export async function resolvedCodexMessageOptions( await applyCodexStructuredSessionOption(selected, key, value) } } - const effort = selected.options.get('effort') - const personality = selected.options.get('personality') - const serviceTier = selected.options.get('serviceTier') - return { - model, - ...(effort ? { effort } : {}), - ...(personality ? { personality } : {}), - ...(serviceTier ? { serviceTier } : {}) - } + return Object.fromEntries(selected.options) } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 5a905b8b56c2..34da4da09e6b 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-start.ts b/src/main/codex/codex-structured-session-start.ts index 74975510502e..b32e4a3f9e81 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -119,6 +119,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 1728683b2114..75955eaf5ae9 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -134,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. - ...(input.turnOptions ?? 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' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts index 81d90583e9ee..571664ae003f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts @@ -117,10 +117,11 @@ it.each([false, true])( optionRevision: -1 }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ - ['model', 'launched-model'], - ['effort', 'low'] - ]) + expect(writes).not.toHaveBeenCalled() + expect(rig.adapter.dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: 'launched-model', + effort: 'low' + }) const options = { model: 'picked-model', effort: 'high', permissionMode: 'full-access' } expect(rig.adapter.closeSession).toHaveBeenCalledTimes(hasOpenDispatch ? 0 : 1) expect(rig.adapter.acquire).toHaveBeenCalledTimes(hasOpenDispatch ? 1 : 2) From 4542252e069446b415654cfadae325020486dfb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:06:12 -0700 Subject: [PATCH 07/31] Align message choice and Pi default fixtures with provider dispatch --- src/main/pi/rpc-configured-default.test.ts | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) 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 () => { From 21eda73db402f8854ea0e2b244bfd5c22c2e299e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:14:02 -0700 Subject: [PATCH 08/31] Align option fixtures with saved message choices --- .../acp/acp-structured-host-live-listing.test.ts | 15 ++++++++++----- ...aude-structured-session-adapter-turns.test.ts | 11 ++++------- ...-session-claude-stop-exit-ends-record.test.ts | 16 +++++----------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 57cca6e00aaf..f62aa69fa143 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -46,12 +46,17 @@ describe('a live Grok listing', () => { providerStarted: vi.fn() } // The rig waits for the child to turn ready: bookkeeping never fails a proven start. - const { store } = await openAttachedHostRig({}, undefined, modelCatalog) + const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull() - // The rest of the start still lands: what it reported is saved. - await vi.waitFor(() => - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'grok-4.7', effort: 'high' }) - ) + // The rest of the start still lands, and live options remain readable. + await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) }) + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current + ).toMatchObject({ + model: 'grok-4.7', + effort: 'high' + }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) }) 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 a48497b84166..0fa3c8f287eb 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -16,13 +16,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { const claude = fakeClaude({ replayUuid: 'chosen-uuid' }) const adapter = await acquired(claude) const connection = claude.connections[0]! - const send = connection.send - connection.send = async (...args) => { - const controls = connection.calls.map((call) => call.subtype) - expect(controls.indexOf('set_model')).toBeGreaterThan(-1) - expect(controls.indexOf('set_model')).toBeLessThan(controls.indexOf('apply_flag_settings')) - return send(...args) - } + const before = connection.calls.length await expect( adapter.dispatch({ sessionId: 'session-1', @@ -32,6 +26,9 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { 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 () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 91c0afd33317..14adfb0f2b6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -451,22 +451,16 @@ it('rejects a message whose start meets a close still unverifiable, and starts n expect(scopes()).toContain('provider-close-unproven') }) -it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { +it('saves an option change while the close stays unverifiable without writing to the old child', async () => { const connection = await stopWithUnprovenClose(2) - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } - } - }) - expect(connection.closeCount).toBe(2) + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.closeCount).toBe(1) expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) - // Joined again once the exit is proven, the pick is the chat's at rest, for the next start. + // The pick remains saved for a later start while the old child is still unverifiable. await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(child()).toBeNull() + expect(child()?.close).toBeDefined() expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) }) From 9c11b4dd847309a4e53b65198240d481012fbd3c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:20:29 -0700 Subject: [PATCH 09/31] Match Codex message choices to the active turn origin --- .../structured-agent-session-message-options.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index 96f6375effd7..81579f05c9f0 100644 --- 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 @@ -17,19 +17,18 @@ function sameOptions( /** A different Codex choice needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( - journal: Pick, + journal: Pick, next: AgentJournalSubmission ): boolean { - const activeTurnId = journal.activeTurnId() - if (!activeTurnId) { + const turn = journal.newestTurn() + if (turn?.state !== 'running') { return false } const active = journal.submissions().find((submission) => { if (submission.handedOverAt === undefined) { return false } - const item = journal.item(agentJournalSubmissionKey(submission.clientMessageId)) - return item?.turnScope?.kind === 'turn' && item.turnScope.turnItemId === activeTurnId + return agentJournalSubmissionKey(submission.clientMessageId) === turn.userItemId }) return active ? !sameOptions(active.executionOptions, next.executionOptions) : true } From 0d0df2bc13c13b19255654bd03ee80be8a35c2fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:30:01 -0700 Subject: [PATCH 10/31] fix: let pending commands reach handover refusal --- ...tructured-agent-session-message-options.ts | 6 ++++- ...tructured-agent-session-stop-event.test.ts | 23 ++++++++++++------- ...structured-conversation-compaction.test.ts | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) 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 index 81579f05c9f0..41e9fe7a9898 100644 --- 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 @@ -17,9 +17,13 @@ function sameOptions( /** A different Codex choice needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( - journal: Pick, + 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 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 ea0dae59199e..424d1fd13c96 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 once it takes // effect, before the interrupt and before anything that ends the child, naming the turn and who // asked; 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 } ) } @@ -185,7 +192,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') @@ -195,10 +202,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()) @@ -217,7 +224,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()) @@ -233,7 +240,7 @@ 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') + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) }) @@ -253,7 +260,7 @@ describe("a Stop's event", () => { it('names a turn that ended while the next card is sent but shows no turn yet: writes, and holds that card', 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-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 } From ac79c22c7c871ab1ce490bfe00230dba62121455 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:54:09 -0700 Subject: [PATCH 11/31] Abort pre-prompt message choice writes with host-owned wait --- src/main/acp/acp-message-execution-options.ts | 13 +++- .../acp-structured-host-option-hang.test.ts | 55 +++++++++++------ src/main/acp/acp-structured-options.ts | 10 ++- .../acp-structured-session-adapter.test.ts | 42 +++++++++++++ .../acp/acp-structured-session-adapter.ts | 11 +++- .../claude-structured-message-options.ts | 61 ++++++++++++++++--- .../claude-structured-session-adapter.ts | 26 +------- .../codex/codex-structured-session-adapter.ts | 22 +++---- ...structured-agent-session-acquire-aborts.ts | 17 +++--- .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-chat-stop.ts | 39 +++++++++++- .../structured-agent-session-delivery-loop.ts | 9 +-- .../structured-agent-session-host-delivery.ts | 5 +- ...structured-agent-session-host-mutations.ts | 15 ++--- .../structured-agent-session-host.ts | 6 +- .../structured-agent-session-turns.ts | 47 +++++++++++--- src/main/pi/rpc-message-dispatch.ts | 21 ++++++- src/main/pi/rpc-session-adapter.test.ts | 44 +++++++++++++ 18 files changed, 341 insertions(+), 104 deletions(-) diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index 9cdf026c687d..9fb2d6540a20 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -11,6 +11,9 @@ export async function applyAcpMessageOptions( 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 = @@ -19,7 +22,15 @@ export async function applyAcpMessageOptions( : 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 || session.options.reported()[key] === value) { + 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 { 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 5fe02884e7f3..3447d401199d 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 } 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,45 +41,59 @@ 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 } = await openAttachedHostRig({ optionWriteTimeoutMs: 100, 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(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') diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 662feed95f66..d4441044aaaa 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -2,6 +2,8 @@ // 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, @@ -243,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' @@ -256,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 diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 2707cbc93447..54c542649140 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -205,6 +205,48 @@ describe('ACP structured session adapter: turns', () => { ]) }) + it('keeps legacy sends on the current selection when execution options are absent', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'override', + body: hello, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'legacy', + body: hello + }) + expect(rig.sent('session/set_config_option')).toHaveLength(2) + expect(rig.sent('session/prompt')).toHaveLength(1) + }) + + it('refuses an omitted effort whose new model default cannot be confirmed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'override-effort', + body: hello, + executionOptions: { model: 'grok-4.7', effort: 'low' } + }) + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'unknown-default', + body: hello, + executionOptions: { model: 'grok-4.6' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/prompt')).toHaveLength(1) + }) + it('does not send a prompt when the agent refuses a message’s model', async () => { const rig = await openAcpAdapterRig({ script: (agent) => diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 1f4caa8a9b2d..bc23a94d7320 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -27,6 +27,7 @@ import type { ProviderStartAttempt } from '../provider-process/provider-acquisit 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 { @@ -179,18 +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: input.sessionId, fence: input.fence, key, value }), + (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, @@ -239,7 +242,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. diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index c33bd2e5e92b..8f96f77e8753 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -1,27 +1,39 @@ +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 + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { - const model = options.model ?? (await defaultModelForMessage(session, timeoutMs)) + throwIfSignalAborted(signal) + const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal)) if (model && readClaudeCurrentModel(session).id !== model) { - await setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs) + 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 setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs) + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs), + signal + ) if ( effort !== 'max' && !session.confirmedOptions.has('effort') && @@ -36,22 +48,28 @@ export async function applyClaudeMessageOptions( 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 setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs) + 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 + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { if (session.configuredDefaults?.model) { return session.configuredDefaults.model @@ -59,7 +77,11 @@ async function defaultModelForMessage( if (!session.options.has('model')) { return undefined } - const response = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + 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) { @@ -67,3 +89,28 @@ async function defaultModelForMessage( } 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-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 384673af0171..25ea3b75254c 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -6,14 +6,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 { applyClaudeMessageOptions } from './claude-structured-message-options' -import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { dispatchClaudeMessage } from './claude-structured-message-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -164,26 +162,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey) } - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { - const session = this.session(input.sessionId) - const messageOptions = input.executionOptions - return dispatchClaudeTurn( - session, - input, - messageOptions === undefined - ? input.beforeDispatch - : async () => { - await input.beforeDispatch?.() - try { - await applyClaudeMessageOptions(session, messageOptions, this.deps.requestTimeoutMs) - } catch (error) { - throw error instanceof AgentSessionOptionRejectedError - ? error - : new AgentSessionOptionRejectedError(error) - } - } - ) - } + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs) compact: NonNullable = (input) => dispatchClaudeCommand(this.session(input.sessionId), input.command) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 27cc10206398..dc239fdb91b7 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,6 +1,5 @@ import * as codexRewind from './codex-structured-rewind' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -225,20 +224,19 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap turnId ) - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - executionOptions?: AgentSessionExecutionOptions - 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?.() - const turnOptions = await resolvedCodexMessageOptions(session, input.executionOptions) + 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/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 9fdc5e9a72b4..a92dee125dc7 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 @@ -4,21 +4,21 @@ // instead of waiting behind a provider that may never answer. export class StructuredAgentSessionAcquireAborts { - private readonly inFlight = new Map() + private readonly inFlight = new Map() /** 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) } - this.inFlight.set(sessionId, controller) + this.inFlight.set(sessionId, { controller, handover }) return { signal: controller.signal, end: () => { - if (this.inFlight.get(sessionId) === controller) { + if (this.inFlight.get(sessionId)?.controller === controller) { this.inFlight.delete(sessionId) } } @@ -26,14 +26,17 @@ export class StructuredAgentSessionAcquireAborts { } /** A no-op when the session has nothing in flight. */ - abort(sessionId: string, reason: string | Error): void { - this.inFlight.get(sessionId)?.abort(typeof reason === 'string' ? new Error(reason) : reason) + abort(sessionId: string, reason: string | Error): boolean { + const active = this.inFlight.get(sessionId) + const stoppedHandover = active?.handover === true && !active.controller.signal.aborted + active?.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 controller of this.inFlight.values()) { + for (const { controller } of this.inFlight.values()) { controller.abort(this.quitReason) } } 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 8e909cbdc7ea..15a9b7df110f 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 @@ -306,6 +306,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & 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`. */ 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 b6cd948fa4dc..2eea6a534293 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 @@ -33,6 +33,11 @@ import { type StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { + STOP_NOTE_CANCELLATION_REQUESTED, + structuredAgentSessionStopNoteScope +} from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -44,7 +49,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, @@ -86,6 +91,21 @@ export function mutateWithChatStop( ) ) const child = context.sessions.get(ctx.sessionId)?.child + const recordRequest = () => { + const stoppedTurn = turnId ?? ctx.journal.activeTurnId() + return ctx.journal.appendItem( + structuredAgentSessionStopNoteIdentity(stoppedTurn ?? envelope.clientOperationId), + { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, + { + fence: ctx.fence, + turnScope: structuredAgentSessionStopNoteScope( + ctx.journal, + stoppedTurn ?? undefined, + ctx.journal.liveTurnScope() + ) + } + ) + } if (child?.close) { // A close an earlier stop began: this Stop joins it, retrying the exit's proof, rather // than asking a child that takes no input to stop again. Its event, issued ahead of that @@ -120,11 +140,24 @@ export function mutateWithChatStop( const record = context.deps.store.getRecord(ctx.sessionId) if (!child || !inFlight) { // Nothing to interrupt, so the answer may wait for the withdrawal. + const noteWrite = params.stoppedPrePrompt + ? recordRequest().catch((error: unknown) => + context.deps.logger.warn('writing the Stop note failed', { + scope: 'chat-stop-note', + sessionId, + error + }) + ) + : Promise.resolve() const withdrewAny = await withdrew - if (withdrewAny) { + if (withdrewAny || params.stoppedPrePrompt) { await tookEffect() } - return { ok: true, value: { ...named, cancelled: withdrewAny } } + await noteWrite + return { + ok: true, + value: { ...named, cancelled: withdrewAny || params.stoppedPrePrompt === true } + } } const reach = hadQueued ? 'unrecorded' : stopReachesUnrecordedWork(ctx, turnId) const priorStop = ctx.journal.stopMarks.latest() 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 2f53fa7ef76a..be29deec69b2 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,7 @@ import type { StructuredAgentSessionStartReadiness } from './structured-agent-se 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 = { sessions: ReadonlyMap @@ -75,10 +76,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 = @@ -232,7 +232,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) @@ -297,7 +297,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-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 8ea0b209246e..ef168d7ce0b3 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 @@ -26,6 +26,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 @@ -52,6 +53,7 @@ export type StructuredAgentSessionConversationDelivery = { export function createStructuredAgentSessionConversationDelivery(input: { deps: StructuredAgentSessionHostDeps + acquireAborts: Pick sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise @@ -122,7 +124,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 e695ca8af89b..6501040da1a7 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 @@ -125,12 +125,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( @@ -151,7 +152,7 @@ function abortAcquireForStop( caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, plan: MutationPlan -): void { +): boolean { const { store } = context.deps if ( !agentSessionMutationAdmitsNow({ @@ -162,9 +163,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( 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 13678a71a95c..c8c5efd1eae9 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' @@ -117,6 +116,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. @@ -332,7 +332,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-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 8fab9ed810db..da1a4d5c21e0 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 @@ -13,6 +13,8 @@ import { 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 @@ -109,18 +111,34 @@ async function dispatchSafely( clientMessageId: string, body: AgentJournalMessageItem, requestedAt: number, - executionOptions: AgentSessionExecutionOptions | undefined + 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', @@ -215,7 +233,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 @@ -249,13 +269,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), - submission.executionOptions - ) + 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/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index 14aa093c0520..c172ba451233 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -6,6 +6,7 @@ import { 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 @@ -53,12 +54,26 @@ export async function dispatchPiRpcMessage( } } 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 || @@ -67,7 +82,10 @@ export async function dispatchPiRpcMessage( continue } try { - await applyPiRpcSessionOption(session.connection, session.selected, key, value) + await waitForPromiseWithSignal( + applyPiRpcSessionOption(session.connection, session.selected, key, value), + input.signal + ) } catch (error) { if (session.connection.closed) { return exited() @@ -81,6 +99,7 @@ export async function dispatchPiRpcMessage( throw error } } + input.signal?.throwIfAborted() if (!isCurrent() || session.connection.closed) { return exited() } diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 3825749522ba..4e0dd224f75e 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -242,6 +242,50 @@ describe('Pi RPC session ownership and delivery', () => { ]) }) + it('keeps legacy sends on the current selection when execution options are absent', async () => { + const h = await setup() + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'override', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'legacy', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] } + }) + expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ + 'set_model' + ]) + expect(h.connection.sent).toHaveLength(2) + }) + + it('refuses an omitted effort whose new model default cannot be confirmed', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'override-effort', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + executionOptions: { model: 'anthropic/model-1', effort: 'low' } + }) + await expect( + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'unknown-default', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(h.connection.sent).toHaveLength(1) + }) + it('applies the displayed default model and effort after different message choices', async () => { const h = await setup() h.connection.requestOverride = (command) => From b2913b203a1387199818c2241a2d9fdce5c44ada Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:24:01 -0700 Subject: [PATCH 12/31] Verify message choices through provider input boundaries --- ...claude-context-usage-unloaded-turn.test.ts | 10 +- .../claude-structured-session-adapter.test.ts | 92 +------------------ .../claude-structured-start-fast-mode.test.ts | 35 ++++++- .../codex/codex-background-terminals.test.ts | 5 +- .../codex/codex-subagent-interrupts.test.ts | 3 +- .../structured-agent-session-chat-stop.ts | 11 +-- ...-agent-session-claude-option-queue.test.ts | 33 ++++--- ...ed-agent-session-conversation-stop.test.ts | 23 +++-- ...ed-agent-session-option-settlement.test.ts | 2 +- .../structured-agent-session-options-read.ts | 9 +- ...ed-agent-session-owed-work-release.test.ts | 6 +- ...sion-startup-attempt-registrations.test.ts | 39 ++------ ...tructured-agent-session-stop-event.test.ts | 2 +- src/main/pi/rpc-session-startup.test.ts | 7 +- 14 files changed, 108 insertions(+), 169 deletions(-) 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-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 8145374dd960..0250722cb2a5 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,97 +125,16 @@ 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 } + reportedOptions: { fastMode: true } }) }) - // 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 () => { + 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: 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 }, - restoreSkippedOptions: [] - }) - }) - - 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 }, - restoreSkippedOptions: [] - }) - answerApply(failure) - await tick() - - // A refusal drops the pick, as main's refused restore did, from the record too, after - // `started`; silence keeps it wanted and unconfirmed. - const skipped = events.filter((event) => event.type === 'options-skipped') - expect(skipped).toEqual( - kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] - ) - 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 () => { - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] - }) const adapter = adapterFor(claude) await adapter.acquire({ @@ -228,11 +143,12 @@ 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. 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/codex/codex-background-terminals.test.ts b/src/main/codex/codex-background-terminals.test.ts index 818c8d2a6a06..c2cfb8deb24a 100644 --- a/src/main/codex/codex-background-terminals.test.ts +++ b/src/main/codex/codex-background-terminals.test.ts @@ -14,6 +14,7 @@ import { terminateCodexBackgroundTerminals } from './codex-background-terminals' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -93,7 +94,7 @@ function commandItem(status: 'inProgress' | 'completed') { /** A turn that starts a dev server and ends with it still running. */ async function backgroundedDevServer(codex: ReturnType): Promise { - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } }) codex.notify('item/started', { threadId: THREAD_ID, @@ -226,7 +227,7 @@ describe('Codex background-command Stop', () => { return { data: [], nextCursor: null } } }) - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } diff --git a/src/main/codex/codex-subagent-interrupts.test.ts b/src/main/codex/codex-subagent-interrupts.test.ts index ea04708fe45c..522b14d19883 100644 --- a/src/main/codex/codex-subagent-interrupts.test.ts +++ b/src/main/codex/codex-subagent-interrupts.test.ts @@ -4,6 +4,7 @@ import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreg import { CodexAppServerRequestError } from './codex-app-server-connection' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -34,7 +35,7 @@ async function runningHelper(routes: Record) { const live = () => codex.connections.at(-1)! const notify = (method: string, params: unknown): void => live().handlers.onNotification?.(method, params) - await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) notify('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'inProgress' } }) notify('item/started', { threadId: THREAD_ID, 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 2eea6a534293..2706302f938e 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 @@ -34,10 +34,7 @@ import { } from './structured-agent-session-stop-wind-down' import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' -import { - STOP_NOTE_CANCELLATION_REQUESTED, - structuredAgentSessionStopNoteScope -} from './structured-agent-session-turn-stop-notes' +import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -98,11 +95,7 @@ export function mutateWithChatStop( { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, { fence: ctx.fence, - turnScope: structuredAgentSessionStopNoteScope( - ctx.journal, - stoppedTurn ?? undefined, - ctx.journal.liveTurnScope() - ) + turnScope: ctx.journal.liveTurnScope() } ) } 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 index 6e047ae5773c..ebd23c423e30 100644 --- 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 @@ -34,6 +34,7 @@ let activeModel: string let dispatch: Mock let order: string[] let setOption: Mock +let releaseOpen: () => void function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { @@ -63,7 +64,6 @@ function adapter(): StructuredAgentSessionAdapter { 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: { @@ -76,6 +76,15 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { + open: () => + new Promise((resolve) => { + releaseOpen = () => + resolve({ + reportedOptions: { model: DEFAULT_MODEL }, + restoreSkippedOptions: [], + optionRevision: 0 + }) + }), acquire, dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -94,6 +103,7 @@ beforeEach(async () => { resetHostTestOperationIds() activeModel = DEFAULT_MODEL order = [] + releaseOpen = () => {} store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -125,7 +135,7 @@ afterEach(async () => { }) describe('Claude structured session options', () => { - it('takes a pick made while the agent starts as intent and applies it, model first, before the first send', async () => { + it('captures picks made while opening for the first message', async () => { const pick = (key: string, value: string) => host.setOption(CALLER, { envelope: envelope('agentSession.setOption', { key, value }), @@ -144,19 +154,14 @@ describe('Claude structured session options', () => { ).toMatchObject({ ok: true }) expect(dispatch).not.toHaveBeenCalled() - // The start's own report was read before the picks, so it is out of date. - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: DEFAULT_MODEL }, - restoreSkippedOptions: [], - optionRevision: 0 - }) + releaseOpen() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual([`model=${PICKED_MODEL}`, 'effort=high', 'dispatch']) - expect(activeModel).toBe(PICKED_MODEL) + expect(order).toEqual(['dispatch']) + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: PICKED_MODEL, + effort: 'high' + }) + expect(activeModel).toBe(DEFAULT_MODEL) await host.flushAllStreamedEvents() expect(store.getRecord(SESSION)?.options).toEqual({ effort: 'high', model: PICKED_MODEL }) }) 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 294faaa86d0d..790865387d89 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 @@ -33,6 +33,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { THREAD_ID, + acquireReadyCodexForTest, adapterFor, answerWithOpenedTurn, fakeCodex, @@ -203,7 +204,7 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-catalog', @@ -215,6 +216,11 @@ describe('a Stop that names no turn', () => { cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) setOption.mockImplementation((input) => adapter.setOption(input)) + const listing = adapter.readOptions({ sessionId: SESSION, fence: 1 }) + 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(() => @@ -225,7 +231,7 @@ describe('a Stop that names no turn', () => { value: { options: { model: 'gpt-next' } } }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - 1 + catalogReads ) const followUp = send('follow-up') @@ -248,6 +254,7 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params ).toMatchObject({ model: 'gpt-next', serviceTier: 'priority' }) pending.resolve({ data: [], nextCursor: null }) + await expect(listing).rejects.toThrow('no available models') }) // Codex can abort a turn before it starts: it answers the interrupt, then ends the turn it never @@ -273,7 +280,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted', @@ -315,7 +322,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-item', @@ -344,7 +351,7 @@ describe('a Stop that names no turn', () => { const codex = fakeCodex() codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-failed', @@ -384,7 +391,11 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-bad' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor(SESSION), + fence: 1, + spawnToken: 'spawn-bad' + }) dispatch.mockImplementation((input) => adapter.dispatch(input)) setOption.mockImplementation((input) => adapter.setOption(input)) 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 a25de059ee78..fc2e3b89ab7a 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 @@ -195,7 +195,7 @@ describe('structured session options and close', () => { await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeNativeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, 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 b2293f125ba1..e6bff05ff6d2 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,8 @@ +import { saveStructuredAgentSessionStartListing } from './structured-agent-session-start-listing' // 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 { refuse, @@ -222,7 +221,7 @@ export async function readStructuredAgentSessionOptions( const { catalogListing, ...answer } = live if (catalogListing) { // What a running child listed is its account's catalog too, so the next chat opens warm. - context.deps.modelCatalog?.recordLiveListing(sessionId, catalogListing) + saveStructuredAgentSessionStartListing(context, sessionId, catalogListing) } options = answer } else { 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-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 64c681c9f9ed..8c55910b3de7 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 @@ -32,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 () => { @@ -64,6 +57,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ store: AgentSessionRecordStore acquire: Mock dispatch: Mock + ready: () => void }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() @@ -90,7 +84,12 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) + let ready = () => {} const adapter: StructuredAgentSessionAdapter = { + open: () => + new Promise((resolve) => { + ready = () => resolve({ reportedOptions: {}, restoreSkippedOptions: [], optionRevision: 0 }) + }), acquire, dispatch, closeSession: vi.fn(async () => true), @@ -114,7 +113,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW }) - return { host, store, acquire, dispatch } + return { host, store, acquire, dispatch, ready: () => ready() } } describe.each( @@ -123,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 } = await hostFor(definition) + const { host, store, acquire, dispatch, ready } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -160,21 +159,11 @@ describe.each( }) expect(sent).toMatchObject({ ok: true }) await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe( - false - ) + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(true) ) expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: record.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) + ready() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) } finally { @@ -182,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-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 424d1fd13c96..95d62a6baa80 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 @@ -239,7 +239,7 @@ describe("a Stop's event", () => { it('naming no turn, names the turn running when it takes effect', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const working = await rig.workingSend() await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index eb426dd06b2e..eb36ecfcd1cf 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -11,7 +11,7 @@ import { afterEach(closePiRpcAdapterFixtures) describe('Pi host awaited open', () => { - it('owns the process before RPC and restores model before effort during open', async () => { + it('owns the process before RPC and leaves message choices for input', async () => { const h = await setup() const stateReply = Promise.withResolvers() const { acquired, child } = await restart( @@ -39,9 +39,8 @@ describe('Pi host awaited open', () => { await expect(opening).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) - expect(child.requests.indexOf('set_model')).toBeLessThan( - child.requests.indexOf('set_thinking_level') - ) + expect(child.requests).not.toContain('set_model') + expect(child.requests).not.toContain('set_thinking_level') expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) From e200450039192d70d35c14c82fff305b4973c86c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:31:20 -0700 Subject: [PATCH 13/31] Keep legacy mobile test hosts explicit about message choices --- .../mobile-native-chat-merged-snapshot-parity-hooks.test.tsx | 1 + .../use-mobile-structured-agent-session-prompt-cancel.test.tsx | 1 + .../use-mobile-structured-agent-session-queued.test-fixture.ts | 1 + mobile/src/session/use-mobile-structured-turn-indicator.test.tsx | 1 + 4 files changed, 4 insertions(+) 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/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-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 }, From 955f83c6832334a73326e7bf8d251ee8a373d01f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:49:33 -0700 Subject: [PATCH 14/31] Preserve message choices across cold clients and kept cards --- .../mobile-structured-option-snapshot.ts | 27 +++++++++++ ...ructured-agent-options.first-frame.test.ts | 44 +++++++++++++++--- .../use-mobile-structured-agent-options.ts | 8 ++-- .../use-mobile-structured-agent-session.ts | 7 +-- ...use-mobile-structured-send-with-outcome.ts | 7 +-- .../use-mobile-structured-sends.test.tsx | 19 ++++++-- ...-mobile-structured-session-option-stamp.ts | 24 ---------- ...mobile-structured-session-option-status.ts | 43 +++++++++++++++++ src/main/acp/acp-structured-acquire.ts | 5 +- .../acp-structured-adapter.test-support.ts | 2 - src/main/acp/acp-structured-handshake.ts | 3 -- .../acp-structured-publish-at-spawn.test.ts | 8 +--- ...claude-structured-dispatch-test-support.ts | 1 - .../claude-structured-event-delivery.ts | 7 --- .../claude/claude-structured-options.test.ts | 1 - .../claude/claude-structured-retired-model.ts | 10 ---- .../claude-structured-session-acquisition.ts | 1 - .../claude/claude-structured-session-open.ts | 4 +- .../claude-structured-session-startup.test.ts | 3 +- .../claude-structured-session-startup.ts | 29 +++--------- .../claude/claude-structured-session-state.ts | 7 +-- .../codex-structured-message-options.test.ts | 34 ++++++++++++++ .../codex-structured-publish-at-spawn.test.ts | 4 +- .../codex/codex-structured-session-start.ts | 5 +- .../agent-model-catalog-entry.ts | 6 +-- .../journal-unsent-send-hold.test.ts | 19 +++++++- .../journal-unsent-send-hold.ts | 3 ++ ...-agent-session-acquisition-options.test.ts | 11 ----- ...-session-acquisition-runtime-stamp.test.ts | 1 - .../structured-agent-session-acquisition.ts | 1 - .../structured-agent-session-adapter.ts | 19 -------- ...tured-agent-session-adopted-import.test.ts | 1 - .../structured-agent-session-attach-flow.ts | 2 - ...ured-agent-session-attach-orchestration.ts | 1 - ...-agent-session-claude-option-queue.test.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 6 +-- .../structured-agent-session-create.test.ts | 4 +- ...ructured-agent-session-drivability.test.ts | 1 - ...structured-agent-session-event-recovery.ts | 4 -- ...d-agent-session-held-start.test-support.ts | 7 +-- ...ctured-agent-session-host-runtime-state.ts | 2 - .../structured-agent-session-host.ts | 1 - ...gent-session-instant-start.test-support.ts | 8 +--- ...ructured-agent-session-mutation-context.ts | 2 - ...ructured-agent-session-option-revisions.ts | 46 ------------------- ...ed-agent-session-option-settlement.test.ts | 6 +-- ...ent-session-pre-spawn-first-answer.test.ts | 1 - ...nt-session-processless-reservation.test.ts | 5 -- ...gent-session-provider-child-record.test.ts | 4 +- ...ed-agent-session-provider-open-contract.ts | 3 -- ...ctured-agent-session-provider-open.test.ts | 4 +- ...t-session-provider-started-catalog.test.ts | 4 +- ...ructured-agent-session-provider-started.ts | 12 +---- ...session-queued-message-rig.test-fixture.ts | 4 +- ...session-send-restarts-failed-start.test.ts | 4 +- ...-agent-session-startup-attempt-contract.ts | 2 - ...agent-session-startup-attempt-host.test.ts | 13 +----- ...sion-startup-attempt-registrations.test.ts | 2 +- ...ured-agent-session-startup-attempt.test.ts | 3 +- ...tructured-agent-session-startup-attempt.ts | 4 +- .../structured-conversation-command.test.ts | 4 +- src/main/pi/rpc-session-adapter.test.ts | 1 - src/main/pi/rpc-session-startup.ts | 4 -- ...ude-structured-retired-saved-model.test.ts | 42 +++++++---------- ...ured-agent-session-failure-logging.test.ts | 4 +- ...tructured-agent-session-pi-startup.test.ts | 5 +- .../structured-claude-runtime-adapter.ts | 2 +- .../NativeChatStructuredSession.tsx | 7 ++- ...se-structured-agent-session-sends.test.tsx | 35 +++++++++++++- .../use-structured-agent-session-sends.ts | 7 +-- ...-session-execution-option-snapshot.test.ts | 32 +++++++++++++ ...agent-session-execution-option-snapshot.ts | 21 +++++---- 72 files changed, 332 insertions(+), 355 deletions(-) create mode 100644 mobile/src/session/mobile-structured-option-snapshot.ts delete mode 100644 mobile/src/session/use-mobile-structured-session-option-stamp.ts create mode 100644 mobile/src/session/use-mobile-structured-session-option-status.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts create mode 100644 src/shared/agent-session-execution-option-snapshot.test.ts 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 dc08a9d31a45..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), @@ -171,8 +173,8 @@ export function useMobileStructuredAgentOptions(args: { ]) const optionSnapshot = useMemo( - () => structuredAgentSessionOptionSnapshot(optionState), - [optionState] + () => mobileStructuredOptionSnapshot(agent ?? 'codex', optionState, hostSelection), + [agent, hostSelection, optionState] ) const setStructuredOption = useCallback( diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b8649d56310f..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 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 3482b1cfd9d4..62eb2fe2a256 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -141,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, @@ -150,9 +153,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), - ...(messageChoices - ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(controller.snapshot) } - : {}), + ...(executionOptions ? { executionOptions } : {}), deadline, clientMessageId: send.clientMessageId, onError: onSendError 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/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 308c75610068..071458ba0a89 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -160,7 +160,6 @@ export async function acquireAcpStructuredSession(input: { input.track(connection) connection.subscribe((event: AcpSessionEvent) => whenLane(() => { - const optionRevision = acquire.optionRevision?.() ?? 0 if (slot.lane) { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) } @@ -175,9 +174,7 @@ export async function acquireAcpStructuredSession(input: { sessionId, fence: acquire.fence, acquisitionGeneration: generation, - reportedOptions: options.read().current, - restoreSkippedOptions: [], - optionRevision + reportedOptions: options.read().current }) } }) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 7049381ae810..3620304c5cd7 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -65,7 +65,6 @@ export type AcpAdapterRig = { signal?: AbortSignal waitForStart?: boolean options?: Readonly> - optionRevision?: () => number }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] @@ -196,7 +195,6 @@ export async function openAcpAdapterRig( spawnToken: 'spawn-1', events: rig.eventSink, ...(acquireOptions.options ? { options: acquireOptions.options } : {}), - ...(acquireOptions.optionRevision ? { optionRevision: acquireOptions.optionRevision } : {}), ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), onSpawned: async () => { spawned.push('onSpawned') diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 9fee50bcd31d..66c34fe2cd63 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -54,7 +54,6 @@ export async function initializeAcpStructuredSession( const now = deps.now ?? Date.now const assertStarting = () => assertAcpStartingSession(child, input.abandoned) const agentName = acpAgentName(spec.agent) - const optionRevision = acquire.optionRevision?.() ?? 0 assertStarting() const initialized = await connection.initialize() assertStarting() @@ -155,8 +154,6 @@ export async function initializeAcpStructuredSession( return { link, reportedOptions: options.read().current, - restoreSkippedOptions: restoreSkipped, - optionRevision, catalogListing: options.startListing() } } 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 94bfea7ed7f0..33e04d733eaa 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -37,8 +37,7 @@ describe('ACP child published before its handshake', () => { }) const acquired = await rig.acquire({ waitForStart: false, - options: { effort: 'low', model: 'grok-4.6' }, - optionRevision: () => 7 + options: { effort: 'low', model: 'grok-4.6' } }) expect(acquired.process.pid).toBe(4242) if (!acquired.acquisitionGeneration) { @@ -59,9 +58,7 @@ describe('ACP child published before its handshake', () => { answer?.() expect(await opening).toMatchObject({ link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, - reportedOptions: { model: 'grok-4.7', effort: 'high' }, - restoreSkippedOptions: [], - optionRevision: 7 + reportedOptions: { model: 'grok-4.7', effort: 'high' } }) expect(writes).toEqual([]) rig.child().agent.notify('session/update', { @@ -167,7 +164,6 @@ describe('ACP child published before its handshake', () => { }) await rig.acquire({ options: { model: 'retired-model' } }) expect(rig.started()).toMatchObject({ - restoreSkippedOptions: [], reportedOptions: { model: 'grok-4.7', effort: 'high' } }) expect(rig.sent('session/set_config_option')).toEqual([]) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index afe0352e2f87..4b9718ae45fe 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -46,7 +46,6 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C proof: createClaudeInitProof(), account: undefined, resumesTranscript: false, - optionRevision: () => 0, onLateRefusal: () => {} } } diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts index 9e543a37a658..5430eb82f8c1 100644 --- a/src/main/claude/claude-structured-event-delivery.ts +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -45,13 +45,6 @@ export function emitClaudeStructuredSessionEvent({ const retired = event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null if (session && retired !== null) { - deps.onEvent?.({ - type: 'options-skipped', - sessionId: event.sessionId, - fence: session.fence, - acquisitionGeneration: session.acquisitionGeneration, - options: { model: retired } - }) resetClaudeRetiredModel(session, deps, event.sessionId, retired) } publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index efc0ce81ad55..64ada44671d7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -55,7 +55,6 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe proof: createClaudeInitProof(), account: undefined, resumesTranscript: false, - optionRevision: () => 0, onLateRefusal: () => {} } } diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index b6433d7a060a..3b233201722b 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -60,13 +60,3 @@ export function resetClaudeRetiredModel( }) ) } - -/** The saved values this child showed it cannot run. Only a retired launch model is one: a launch - * never skips a model, so a skipped model is that. */ -export function claudeRetiredOptions( - session: Pick -): { retiredOptions?: Record } { - return session.restoreSkippedOptions.has('model') && session.launchedModel !== null - ? { retiredOptions: { model: session.launchedModel } } - : {} -} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index d65c3bcfd90e..bba3eb1a1ee3 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -242,7 +242,6 @@ export async function acquireClaudeSession({ proof: initProof, account: launch.account, resumesTranscript: launch.resumesTranscript, - optionRevision: () => input.optionRevision?.() ?? 0, onLateRefusal: (error) => callbacks.handleExit(sessionId, attempt, error) }, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), diff --git a/src/main/claude/claude-structured-session-open.ts b/src/main/claude/claude-structured-session-open.ts index 55e4a7fbc828..f1870a7600c5 100644 --- a/src/main/claude/claude-structured-session-open.ts +++ b/src/main/claude/claude-structured-session-open.ts @@ -27,8 +27,7 @@ export async function openClaudeStructuredSession(input: { ) { throw new Error('claude session was replaced before open') } - const { proof, account, resumesTranscript, optionRevision, onLateRefusal } = - session.initialization + const { proof, account, resumesTranscript, onLateRefusal } = session.initialization const opened = openClaudeSession({ session, facts: readClaudeStartupFacts({ @@ -46,7 +45,6 @@ export async function openClaudeStructuredSession(input: { isCurrent: () => sessions.get(request.sessionId) === session, diagnose: (diagnostic) => emit(session, { type: 'auth-diagnostic', sessionId: request.sessionId, diagnostic }), - optionRevision, onLateRefusal, report: (event) => emit(session, { diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index ffbd756b7930..a36d490ecc4a 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -34,8 +34,7 @@ describe('Claude host-awaited open', () => { await vi.advanceTimersByTimeAsync(1) await expect(open).resolves.toMatchObject({ link: expect.objectContaining({ handle: expect.objectContaining({ agent: 'claude' }) }), - reportedOptions: expect.any(Object), - optionRevision: 0 + reportedOptions: expect.any(Object) }) expect(events.map((event) => event.type)).not.toContain('started') await adapter.closeAll() diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 9131138ad40c..b4c9c3924d08 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -2,7 +2,6 @@ // independently; the host owns a failed open and its single close. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { StructuredAgentSessionOptionsSkippedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' @@ -18,7 +17,6 @@ import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' -import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, @@ -35,19 +33,13 @@ import type { export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, - | 'reportedOptions' - | 'restoreSkippedOptions' - | 'retiredOptions' - | 'optionRevision' - | 'catalogListing' + 'reportedOptions' | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ -export type ClaudeStartupReport = - | ({ - type: 'options-reported' - } & ClaudeOpenFacts) - | Pick +export type ClaudeStartupReport = { + type: 'options-reported' +} & ClaudeOpenFacts /** What the initialize answer proved: enough to start. */ export type ClaudeInitializeFacts = { @@ -203,7 +195,6 @@ export async function openClaudeSession(input: { readSettings: () => Promise isCurrent: () => boolean diagnose: (diagnostic: ClaudeAuthDiagnostic) => void - optionRevision: () => number onLateRefusal: (error: Error) => void report: (event: ClaudeStartupReport) => void }): Promise { @@ -223,10 +214,7 @@ export async function openClaudeSession(input: { session, readClaudeModels(initialized.initialization), session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined - ), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision: input.optionRevision() + ) } void settleClaudeStartupSettings(input, initialized) return result @@ -238,8 +226,6 @@ async function settleClaudeStartupSettings( ): Promise { const { session } = input const sequence = session.optionMutationSequence - // Only a pick moves this stamp; a later report cannot replace a newer choice. - const optionRevision = input.optionRevision() const settings = await input.readSettings() if (!input.isCurrent()) { return @@ -269,9 +255,6 @@ async function settleClaudeStartupSettings( reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) .reportedOptions, // The one report of what the config resolved: the readback just applied it. - ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision + ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 228453adb69e..1a120add4083 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -3,10 +3,7 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsReportedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -64,7 +61,6 @@ export type ClaudeStructuredSessionEvent = } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } | StructuredAgentSessionOptionsReportedEvent - | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' sessionId: string @@ -208,7 +204,6 @@ export type ClaudeSession = { proof: ClaudeInitProof account: ClaudeStructuredLaunch['account'] resumesTranscript: boolean - optionRevision: () => number onLateRefusal: (error: Error) => void } } diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 57d87d9c6559..3f35777c4848 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { agentSessionExecutionOptionsFromSnapshot } from '../../shared/agent-session-execution-option-snapshot' +import { + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' @@ -304,6 +310,34 @@ describe('Codex captured message choices', () => { expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) }) + it('uses a saved Codex choice when the picker has not loaded', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'saved-turn' } + })) + const session = optionSession(request) + session.options.set('model', 'gpt-saved-unlisted') + session.options.set('effort', 'high') + session.reportedOptions = { model: 'gpt-saved-unlisted', effort: 'high' } + delete session.catalogAccess + delete session.nativeDefaults + const seed = structuredAgentSessionSeedCatalog('codex') + const snapshot = structuredAgentSessionOptionSnapshot( + createStructuredAgentSessionOptionState('codex', seed) + ) + const captured = agentSessionExecutionOptionsFromSnapshot(snapshot) + expect(captured).toBeUndefined() + const turnOptions = await resolvedCodexMessageOptions(session, captured) + await startCodexTurn(session, { + clientMessageId: 'saved', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls[0]?.[1]).toMatchObject({ + model: 'gpt-saved-unlisted', + effort: 'high' + }) + }) + it('sends a fresh default while model discovery is held', async () => { const request = vi.fn(async (method: string, _params?: Record) => { if (method === 'model/list') { diff --git a/src/main/codex/codex-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts index 523c8c94f548..581092306e51 100644 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -59,9 +59,7 @@ describe('Codex host-awaited open', () => { threaded.resolve({ thread: { id: THREAD_ID }, model: 'gpt-live' }) expect(await opening).toMatchObject({ link: { handle: { nativeId: THREAD_ID } }, - reportedOptions: { model: 'gpt-saved' }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: 'gpt-saved' } }) expect(events.some((event) => event.type === 'started')).toBe(false) await adapter.closeAll() diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 2e316ca40e51..b98887ece3b6 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -76,7 +76,6 @@ export async function openCodexStructuredSession( assertCodexConnectionOpen(connection, sessionId) } current() - const optionRevision = input.optionRevision?.() ?? 0 await waitForPromiseWithSignal( initializeCodexAppServerConnection(connection, STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS), openInput.signal @@ -163,8 +162,6 @@ export async function openCodexStructuredSession( linkId: deps.mintLinkId?.(), observedAt: deps.now?.() ?? Date.now() }), - reportedOptions: reportedCodexSessionOptions(session), - restoreSkippedOptions: [], - optionRevision + reportedOptions: reportedCodexSessionOptions(session) } } 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-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-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 774f87be548d..43a00cda8106 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 @@ -166,7 +166,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: (attached) => { @@ -206,7 +205,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -240,7 +238,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, recordPhase, @@ -272,7 +269,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -305,7 +301,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -344,7 +339,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -381,7 +375,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-standard', fastMode: 'true' @@ -418,7 +411,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), now: () => NOW, onAttached: () => {} @@ -456,7 +448,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -543,7 +534,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(operationId, fence), now: () => NOW, onAttached: () => {} @@ -644,7 +634,6 @@ describe('the tab a create reserves', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which // conversation this attaches to. params: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts index d47f8650abca..90c94306db1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -114,7 +114,6 @@ it('starts a chat whose recorded owner carries the runtime stamp', async () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} 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 168d41831162..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 @@ -74,7 +74,6 @@ export async function acquireOwner( ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - optionRevision: input.optionRevision, ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) }) const progress = input.onStartupAttempt?.(attempt) 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 5f6b287a6502..56f8f78fb13c 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 @@ -216,26 +216,8 @@ export type StructuredAgentSessionStartedEvent = { /** What the child proved, snapshotted by the adapter from what startup already read. The host * handles this inside the session's serialized step, so it must not ask the CLI. */ reportedOptions: AgentSessionOptionsResult['current'] - /** Saved options the child could not take; the host drops them rather than persist them. */ - restoreSkippedOptions: readonly string[] - /** Values the child showed it cannot run: a report naming the same value is not persisted. */ - retiredOptions?: Readonly> /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ catalogListing?: AgentModelCatalogLiveListing - /** The attempt's `optionRevision()` as the read behind this report began: a pick the host took - * since makes it out of date. An earlier report never does, whenever the host took it. */ - optionRevision: number -} - -/** A running child showed saved options it cannot run, as a model the provider reports missing. - * The record drops them, so the next start uses the provider's own. */ -export type StructuredAgentSessionOptionsSkippedEvent = { - type: 'options-skipped' - sessionId: string - fence: number - acquisitionGeneration: string - /** Each saved value the child showed it cannot run; a record holding another value keeps it. */ - options: Readonly> } /** What a child that already proved its start reports later, such as an optional read that came @@ -249,7 +231,6 @@ export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent - | StructuredAgentSessionOptionsSkippedEvent /** Whether the provider child behind an acquisition has proven its start. A publish-first * acquire hands over a `starting` child, which the host gives no input until the `started` diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 79d5c5d44923..93bfc64bae2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -137,7 +137,6 @@ async function attach( probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(transcriptPath), now: () => NOW, onAttached 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 63f6aba1953e..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,8 +76,6 @@ export type AttachFlowInput = { ) => StructuredAgentSessionStartupProgress /** A completed acquisition's start listing. */ onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void - /** The conversation's option revision, which the child's reports are stamped with. */ - optionRevision: () => number startupOptions?: Readonly> /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( 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 71f50ed697b0..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 @@ -206,7 +206,6 @@ async function runAttachUnderAbort( attempt.startup = startup return startupAttempts.track(sessionId, startup) }, - optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, 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 index 1c2b6a5eb8c0..f35fb1d995a7 100644 --- 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 @@ -81,9 +81,7 @@ function adapter(): StructuredAgentSessionAdapter { new Promise((resolve) => { releaseOpen = () => resolve({ - reportedOptions: { model: DEFAULT_MODEL }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: DEFAULT_MODEL } }) }), acquire, 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 ad4e098c2b0c..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,10 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - if (closed) { - host.context().runtimeState.optionRevisions.forget(sessionId) - } - return closed } const idleSweep = new StructuredAgentSessionIdleSweep({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index da6bee62a150..e6e05f91d6cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -215,9 +215,7 @@ it('holds the created first message until the child proves its start, then hands sessionId: SESSION, fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: generation, - reportedOptions: { model: 'gpt-live' }, - restoreSkippedOptions: [], - optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'gpt-live' } }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index 2814638e0413..bbcadedabfef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -143,7 +143,6 @@ async function attach(input: { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: input.params, now: () => NOW, onAttached: async (attached) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index d0b3ca564ba6..141afd9406eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -12,7 +12,6 @@ import type { import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionOptionsSkipped, settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import { @@ -139,9 +138,6 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } - if (event.type === 'options-skipped') { - return settleStructuredAgentSessionOptionsSkipped(this.context, event) - } await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index 2a8dbf2f98f9..ebcd15553a78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -7,9 +7,6 @@ import type { type Acquire = StructuredAgentSessionAdapter['acquire'] -/** A report read at this revision is never admitted, so the empty report persists nothing. */ -const NO_OPTIONS_REPORTED = -1 - export type StructuredAgentSessionHeldStarts = { /** Wraps an acquire so the child it answers stays `starting`. */ held(acquire: Acquire): Acquire @@ -51,9 +48,7 @@ export function structuredAgentSessionHeldStarts( return adapter.open ? adapter.open(input) : { - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED + reportedOptions: { model: '' } } } return { ...adapter, acquire, open } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index cbae46441bfe..80050667e277 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -12,7 +12,6 @@ import { heldProviderChildReader, type ProviderChildSessions } from './structured-agent-session-provider-child' -import { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' import { recordAgentSessionStartup } from '../../observability/agent-session-instrumentation' import { @@ -25,7 +24,6 @@ export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() readonly acquireAborts = new StructuredAgentSessionAcquireAborts() readonly startupAttempts: StructuredAgentSessionStartupAttempts - readonly optionRevisions = new StructuredAgentSessionOptionRevisions() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void 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 c8c5efd1eae9..17a83e2463d8 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 @@ -293,7 +293,6 @@ export class StructuredAgentSessionHost { stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), acquireAborts: this.runtimeState.acquireAborts, - optionRevisions: this.runtimeState.optionRevisions, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index b0463101fb35..a1a99ce71836 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -5,10 +5,6 @@ import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' -/** A report read at this revision is never admitted, so the fake's empty report persists nothing, - * as a fake with no options to report never did. */ -const NO_OPTIONS_REPORTED = -1 - export function startsWhenPublished( adapter: A, _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } @@ -25,9 +21,7 @@ export function startsWhenPublished( open: adapter.open ?? (async () => ({ - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED + reportedOptions: { model: '' } })) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 63afe25e4f26..cb9a3d3f499f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -14,7 +14,6 @@ import { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionCaller, @@ -52,7 +51,6 @@ export type StructuredAgentSessionMutationContext = { * outside that serialize aborts. */ acquireAborts: Pick /** Moved by every pick a running child takes, so a report it read before is never persisted. */ - optionRevisions: Pick now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts deleted file mode 100644 index 0c20a84c1f59..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts +++ /dev/null @@ -1,46 +0,0 @@ -// The order of what a conversation's record learns about its child's options. -// -// A pick and a child's report both write the record's options, but a report describes the child as -// of its read, which can predate a pick still in flight, a saved value the child showed it cannot -// run, or a newer report. A pick or such a value moves the revision; a report carries the revision -// it was read at and is admitted only if nothing moved it since. Reports are ordered among -// themselves by arrival, so a read's stamp never depends on when an earlier report was taken, and -// the newest admitted report is the only one persisted. - -export class StructuredAgentSessionOptionRevisions { - private readonly revisions = new Map() - private readonly newestReports = new Map() - private reports = 0 - - current(sessionId: string): number { - return this.revisions.get(sessionId) ?? 0 - } - - /** What any report read before now says is out of date. */ - advance(sessionId: string): number { - const next = this.current(sessionId) + 1 - this.revisions.set(sessionId, next) - return next - } - - /** A report read at `readAt` becomes the newest word, or null when a pick came since its read. */ - admitReport(sessionId: string, readAt: number): number | null { - if (readAt !== this.current(sessionId)) { - return null - } - const admitted = ++this.reports - this.newestReports.set(sessionId, admitted) - return admitted - } - - /** Whether the report admitted as `admitted`, read at `readAt`, is still the newest word. */ - isNewest(sessionId: string, readAt: number, admitted: number): boolean { - return readAt === this.current(sessionId) && this.newestReports.get(sessionId) === admitted - } - - /** The conversation closed with no child: nothing it reported is still to be persisted. */ - forget(sessionId: string): void { - this.revisions.delete(sessionId) - this.newestReports.delete(sessionId) - } -} 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 867207fb4e06..0556f181d787 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 @@ -64,11 +64,7 @@ function adapter(): StructuredAgentSessionAdapter { sessionId: identity.sessionId, fence, acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - restoreSkippedOptions: [], - optionRevision: host - .collaboratorsForTests() - .runtimeState.optionRevisions.current(identity.sessionId) + reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } }) return { providerChildPhase: 'starting' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index c9ecb34dad48..2d82658a1bd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -100,7 +100,6 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: createParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index bebf68bd2f34..543abc1e2537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -97,7 +97,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -146,7 +145,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -186,7 +184,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -240,7 +237,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -310,7 +306,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 8cbd7e4e5d92..5d31de269803 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -224,9 +224,7 @@ function prove(): Promise { type: 'started', ...child, acquisitionGeneration, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'default' } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index 3e60c6ee17a1..981636f2caad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -13,10 +13,7 @@ export type StructuredAgentSessionProviderOpenInput = { export type StructuredAgentSessionProviderOpenResult = { link?: AgentSessionProviderHandleLink reportedOptions: AgentSessionOptionsResult['current'] - restoreSkippedOptions: readonly string[] - retiredOptions?: Readonly> catalogListing?: AgentModelCatalogLiveListing - optionRevision: number } export type StructuredAgentSessionProviderOpen = ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 385011d0a767..d5cd92b1e4a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -18,9 +18,7 @@ import { } from './structured-agent-session-host-test-data' const OPENED: StructuredAgentSessionProviderOpenResult = { - reportedOptions: { model: 'gpt-test' }, - restoreSkippedOptions: [], - optionRevision: -1 + reportedOptions: { model: 'gpt-test' } } function awaitOpen() { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 883b63dfaf7d..a256f02037e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -87,9 +87,7 @@ function proveStart(): Promise { sessionId: SESSION, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: 'sonnet' } }) } 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 22aae02db81e..00892757a22b 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 @@ -2,7 +2,6 @@ import type { StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' import type { @@ -160,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 @@ -181,11 +179,3 @@ export async function settleStructuredAgentSessionOptionsReported( } return undefined } - -/** A rejected provider value is live protocol evidence; it does not change a later pick. */ -export function settleStructuredAgentSessionOptionsSkipped( - _context: StructuredAgentSessionProviderStartedContext, - _event: StructuredAgentSessionOptionsSkippedEvent -): Promise { - return Promise.resolve() -} 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 9af72d823e5e..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 @@ -269,9 +269,7 @@ export async function createQueuedMessageTestRig( sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'default' } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index f347dc738615..ae7359e7566f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -101,9 +101,7 @@ function proveStarted(): Promise { return host.handleAdapterEvent({ type: 'started', ...currentChild(), - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'sonnet' } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 297b5e396cd2..14767b058d1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -41,6 +41,4 @@ export type StructuredAgentSessionStartupAttempt = { readonly signal?: AbortSignal /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void - /** The conversation's option revision now; a report stamps it as its read begins. */ - readonly optionRevision: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 350c842858d6..f32d72449f16 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -170,21 +170,13 @@ async function submission(id: string): Promise { expect(input.onOutput).toEqual(expect.any(Function)) expect(input.attemptId).toEqual(expect.any(String)) expect(input.signal).toBeInstanceOf(AbortSignal) - expect(input.optionRevision!()).toBe( - host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - ) }) }) 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 8c55910b3de7..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 @@ -88,7 +88,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const adapter: StructuredAgentSessionAdapter = { open: () => new Promise((resolve) => { - ready = () => resolve({ reportedOptions: {}, restoreSkippedOptions: [], optionRevision: 0 }) + ready = () => resolve({ reportedOptions: {} }) }), acquire, dispatch, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts index 661e0044b8eb..cc71a92590aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts @@ -34,8 +34,7 @@ function mint() { agent: 'codex', providerHandle: null }, - spawnToken: 'spawn-1', - optionRevision: () => 0 + spawnToken: 'spawn-1' }) } 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 4884f1a3e39e..aa5a7e59979f 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 @@ -26,7 +26,6 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal - optionRevision: () => number startupOptions?: Readonly> }): StructuredAgentSessionStartupAttempt { const { record } = input @@ -44,8 +43,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { ...(options ? { options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), - ...(input.signal ? { signal: input.signal } : {}), - optionRevision: input.optionRevision + ...(input.signal ? { signal: input.signal } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index fca0340c41ca..7191ada60a86 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -113,10 +113,8 @@ beforeEach(async () => { sessionId: input.identity.sessionId, fence: input.fence, acquisitionGeneration, - reportedOptions: { model: '' }, - restoreSkippedOptions: [], + reportedOptions: { model: '' } // Never admitted, so the empty report persists nothing. - optionRevision: -1 }) return { providerChildPhase: 'starting' as const, diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 589658e81d9f..f88f043977fa 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -171,7 +171,6 @@ describe('Pi RPC session ownership and delivery', () => { origin: 'created', mintedAtFence: 7 }) - expect(h.started.restoreSkippedOptions).toEqual([]) expect(h.connection.requests).not.toContain('set_thinking_level') expect(h.connection.requests).toContain('get_commands') }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index 7531eeea9aff..0dce12c54244 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -45,7 +45,6 @@ export class PiRpcSessionStartup { launch: PiRpcResolvedLaunch, signal: AbortSignal ): Promise { - const optionRevision = this.input.optionRevision?.() ?? 0 const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST), signal) this.assertLive(signal) const state = piRpcStateSchema.parse(answer) @@ -82,7 +81,6 @@ export class PiRpcSessionStartup { options, unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, - picked: new Set(), ...options }) ).catalogListing @@ -93,8 +91,6 @@ export class PiRpcSessionStartup { return { link, reportedOptions: options?.current ?? current, - restoreSkippedOptions: skipped, - optionRevision, ...(catalogListing ? { catalogListing } : {}) } } diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index d4743996f63a..53a346f18be9 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -1,6 +1,5 @@ -// A chat's saved model rides the launch unchecked. When the CLI answers a turn by saying that model -// does not exist, the record drops it, so the next start runs the CLI's own default; the user's -// message and the CLI's own answer stay as they are. Against the production runtime, adapter, +// A chat's saved model rides the launch unchecked. A missing-model reply retires the live child's +// choice; provider reports leave the saved pick alone. Against the production runtime, adapter, // record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -55,17 +54,18 @@ async function startedWith(options: Record): Promise { - it('drops the model from the record, keeping the rest, so the next start runs the default', async () => { + it('keeps the saved pick while the live child retires its missing model', async () => { const host = await startedWith({ model: RETIRED, effort: 'high' }) expect(claude.child(SESSION).launch.options.model).toBe(RETIRED) expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) - - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') - ) - expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ effort: 'high' }) + await host.flushStreamedEvents(SESSION) + expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ + model: RETIRED, + effort: 'high' + }) }) it("keeps a model the reply did not come from: a subagent's own error", async () => { @@ -97,21 +97,13 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { }) expect(changed, JSON.stringify(changed)).toMatchObject({ ok: true }) expect(record()?.options?.model).toBe('opus') - - await host.handleAdapterEvent({ - type: 'options-skipped', - sessionId: SESSION, - fence: record()?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-heal', - options: { model: RETIRED } - }) - + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + await host.flushStreamedEvents(SESSION) expect(record()?.options?.model).toBe('opus') }) - // A slow settings readback lets the turn's error land first; the report that follows it still - // names the CLI's own model, which is the retired one, and must not write it back. - it('does not let a settings report that lands after the heal write the retired model back', async () => { + // A slow settings readback can report the retired live model after the error. + it('leaves the saved pick alone when that settings report arrives late', async () => { claude.behave(SESSION, { sendsNoStartFrame: true, startupSettingsReadHangs: true, @@ -132,9 +124,9 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { model: RETIRED }) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') - ) + await host.flushStreamedEvents(SESSION) + expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) // The settings read gives up at its deadline and reports what the child showed. await vi.waitFor( @@ -142,6 +134,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { { timeout: 5_000 } ) await host.flushStreamedEvents(SESSION) - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) }) }) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index a9ba53700dd3..33d03da50fc2 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -110,9 +110,7 @@ describe('failures the desktop host used to drop', () => { sessionId: SESSION, fence: 1, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: 'gpt-5' } }) lifecycle.deliver({ type: 'ended', 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 19403cfa5aa8..7ad84c5024f8 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -140,11 +140,8 @@ it('keeps a model picked during startup when the optional state read predates ap const wire = script.wire() expect(wire.indexOf('set_model')).toBeGreaterThan(-1) expect(wire.indexOf('set_model')).toBeLessThan(wire.indexOf('prompt:first message')) - const { runtimeState, sessions, serialize } = host.collaboratorsForTests() + const { sessions, serialize } = host.collaboratorsForTests() await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) - await vi.waitFor(() => - expect(runtimeState.optionRevisions.current(HOST_TEST_SESSION)).toBeGreaterThan(1) - ) await serialize(HOST_TEST_SESSION, async () => {}) expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options?.model).toBe( fields.value diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index f331d774c615..ddecee82e6e7 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -43,7 +43,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'options-reported' || event.type === 'options-skipped') { + if (event.type === 'options-reported') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ce911de145c..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] @@ -87,6 +91,7 @@ export function NativeChatStructuredSession( queueFollowUps, hostStopping: hostExecution.stopping, 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/use-structured-agent-session-sends.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx index 8ff5c03ae37b..0b4b18dbf371 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 @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -11,11 +11,44 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' +import { + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' const QUEUEING = { capability: 'supported', enabled: true } as const afterEach(() => { resetStructuredAgentSessionSendsForTests() + mocks.call.mockReset() +}) + +it('omits choices when Send precedes the picker’s model answer', async () => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const snapshot = structuredAgentSessionOptionSnapshot( + createStructuredAgentSessionOptionState('codex', structuredAgentSessionSeedCatalog('codex')) + ) + const { result } = renderHook(() => + useStructuredAgentSessionSends({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [], + queuedMessageIds: [], + queue: QUEUEING, + historyLoaded: false, + executionOptionSnapshot: snapshot + }) + ) + act(() => { + result.current.send('before picker answer') + }) + await waitFor(() => + expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.send')).toBe(true) + ) + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.send') + expect(call?.[2]).not.toHaveProperty('executionOptions') }) // The composer moves the reader only for a message the transcript draws as a bubble. 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 04cc1eba9490..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 @@ -95,15 +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 } : {}), - ...(executionOptionSnapshot - ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) } - : {}), + ...(executionOptions ? { executionOptions } : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. 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 index 1e80ee67151e..d0ffac39a05f 100644 --- a/src/shared/agent-session-execution-option-snapshot.ts +++ b/src/shared/agent-session-execution-option-snapshot.ts @@ -4,16 +4,19 @@ import type { AgentSessionExecutionOptions } from './agent-session-execution-opt /** What the picker displayed at Send, excluding launch-only permission. */ export function agentSessionExecutionOptionsFromSnapshot( snapshot: readonly SessionOptionDescriptor[] -): AgentSessionExecutionOptions { +): 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 snapshot) { - if ( - descriptor.transport !== 'agent-session' || - descriptor.valueSource === 'unknown' || - !['model', 'effort', 'personality', 'fastMode', 'serviceTier'].includes(descriptor.id) - ) { - continue - } + for (const descriptor of descriptors) { const value = descriptor.kind.currentValue if (value !== undefined) { selected[descriptor.id] = String(value) From 9d553b114fa4979261f5d88113292a82c9f3443d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:39:35 -0700 Subject: [PATCH 15/31] Remove the unused provider option restore ledger --- ...claude-structured-dispatch-test-support.ts | 1 - ...claude-structured-effort-reporting.test.ts | 1 - .../claude-structured-model-preflight.test.ts | 1 - .../claude/claude-structured-options.test.ts | 1 - .../claude-structured-retired-model.test.ts | 5 +---- .../claude/claude-structured-retired-model.ts | 5 ++--- .../claude-structured-session-adapter.ts | 3 --- .../claude-structured-session-publication.ts | 1 - .../claude/claude-structured-session-state.ts | 1 - .../claude-structured-spawn-options.test.ts | 5 ----- .../claude/claude-structured-spawn-options.ts | 21 ++++--------------- ...-agent-session-acquisition-options.test.ts | 9 ++------ ...structured-agent-session-adapter-router.ts | 3 --- .../structured-agent-session-adapter.ts | 2 -- ...ctured-agent-session-option-restoration.ts | 7 +------ .../agent-session-record-options.test.ts | 14 ------------- ...ude-structured-retired-saved-model.test.ts | 2 -- 17 files changed, 10 insertions(+), 72 deletions(-) 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-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-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.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.ts b/src/main/claude/claude-structured-session-adapter.ts index 25ea3b75254c..774e42397397 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -243,9 +243,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-state.ts b/src/main/claude/claude-structured-session-state.ts index 1a120add4083..2068b5d64fe1 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -168,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 fe28be8f83c7..f279b6377313 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -39,8 +39,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 +54,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,16 +66,12 @@ 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; the first message applies a saved Fast // when launch settings cannot carry it. @@ -92,22 +85,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/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 43a00cda8106..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(), @@ -353,13 +349,12 @@ describe('structured session acquisition options', () => { }) }) - it('keeps a saved Fast choice when the provider skips restoring it', 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({ 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 34b3b6d4009b..50e104886bff 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 @@ -195,9 +195,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 56f8f78fb13c..faa659eff8b7 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 @@ -406,8 +406,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-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index b1ec8d997a93..4374e3e0b5ef 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,7 +15,6 @@ export async function readNativeSessionOptions(input: { } return nativeSessionOptionsFromReport({ reported: reported.current, - restoreSkipped: adapter.readOptionRestoreFailures?.(sessionId) ?? [], ...(priorOptions ? { priorOptions } : {}) }) } @@ -24,7 +23,6 @@ export async function readNativeSessionOptions(input: { * replace the saved ones, other saved options stay, and any the child could not take are dropped. */ 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 +33,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/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-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index 53a346f18be9..05730f9b6835 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -61,7 +61,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) await host.flushStreamedEvents(SESSION) - expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ model: RETIRED, effort: 'high' @@ -125,7 +124,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { }) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) await host.flushStreamedEvents(SESSION) - expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) // The settings read gives up at its deadline and reports what the child showed. From e15f2f3089e158b63f5161608a393101cc1daf1a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:40:58 -0700 Subject: [PATCH 16/31] Clarify launch choices and legacy option hydration --- src/main/claude/claude-structured-spawn-options.ts | 4 +--- .../structured-agent-session-option-restoration.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index f279b6377313..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' 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 4374e3e0b5ef..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 @@ -19,8 +19,7 @@ export async function readNativeSessionOptions(input: { }) } -/** 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'] priorOptions?: Readonly> From 38a82587dc2bcca389ddc1e1021a62cadc1307c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:43:10 -0700 Subject: [PATCH 17/31] Remove remaining restore-ledger forwarding and assertions --- src/main/acp/acp-structured-missing-model.test.ts | 3 +-- src/main/claude/claude-structured-acquisition-launch.ts | 1 - .../claude-structured-effort-default-at-rest.test.ts | 4 +--- .../claude-structured-options-context-window.test.ts | 1 - src/main/claude/claude-structured-session-adapter.test.ts | 3 +-- src/main/codex/codex-structured-service-tier.test.ts | 7 +++---- 6 files changed, 6 insertions(+), 13 deletions(-) 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/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-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-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-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 0250722cb2a5..6b93a2b99431 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -152,7 +152,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) // 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') @@ -160,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/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' + }) }) }) From 331146cfa547285b12e94e3365fe498d2dd5ad2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:45:46 -0700 Subject: [PATCH 18/31] Remove unused ACP and Pi restore bookkeeping --- src/main/acp/acp-structured-acquire.ts | 1 - src/main/acp/acp-structured-child.ts | 1 - src/main/acp/acp-structured-handshake.ts | 4 +--- src/main/acp/acp-structured-session-adapter.ts | 5 ----- src/main/acp/acp-structured-session.ts | 1 - src/main/pi/rpc-session-adapter.ts | 3 --- src/main/pi/rpc-session-startup.ts | 3 --- 7 files changed, 1 insertion(+), 17 deletions(-) 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 66c34fe2cd63..71dcfd0adb32 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -102,7 +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: string[] = [] const link: AgentSessionProviderHandleLink = { linkId: deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), @@ -145,8 +144,7 @@ export async function initializeAcpStructuredSession( agentName, now, settle: onSettled - }), - restoreSkipped + }) }) if (!isAcpReadySession(child)) { throw new Error('ACP session did not open') diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 3bb2d594dcb1..450d1fc1cbcc 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -261,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/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 7d8985007e6f..a70f4419a46d 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -215,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 0dce12c54244..e4db9740f20a 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -26,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 @@ -63,7 +62,6 @@ export class PiRpcSessionStartup { model: state.model ? piModelOptionId(state.model.provider, state.model.id) : '', ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}) } - const skipped: string[] = [] 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 @@ -85,7 +83,6 @@ export class PiRpcSessionStartup { }) ).catalogListing } - this.skipped = skipped await this.readCommands(signal) this.assertLive(signal) return { From ce3deb5730588c780f6175608a6fb185b805228a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:16:22 -0700 Subject: [PATCH 19/31] Check running choices and retained rewind messages after delivery integration --- ...red-agent-session-claude-option-queue.test.ts | 11 ++++++----- ...tured-agent-session-option-settlement.test.ts | 4 ++-- ...tructured-agent-session-provider-open.test.ts | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) 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 index 4d4ab410b804..47b7b3901016 100644 --- 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 @@ -182,13 +182,14 @@ it('applies model and effort picks on a running Claude chat to the next message' ).toMatchObject({ ok: true }) } const body = hostTestMessage('with the new choices') - dispatch.mockImplementationOnce(async () => { - expect(setOption).not.toHaveBeenCalled() - 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([]) + expect(order).toEqual(['dispatch']) + expect(setOption).not.toHaveBeenCalled() + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: PICKED_MODEL, + effort: 'high' + }) }) 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 29102750b379..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' @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index e3e3677de6a7..af111436b03c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -309,6 +309,18 @@ it.each([false, true])( expectedEpoch }) await host.collaboratorsForTests().serialize(SESSION, async () => {}) + for (const [key, value] of [ + ['model', 'gpt-after-rewind'], + ['effort', 'high'] + ]) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } const later = send('later message') let accepted = false void later.then(() => { @@ -331,6 +343,10 @@ it.each([false, true])( expect(await later).toMatchObject({ ok: true }) await vi.waitFor(() => expect(order).toHaveLength(2)) expect(order).toEqual(['rewind', 'send']) + expect(dispatch.mock.calls.at(-1)?.[0].executionOptions).toEqual({ + model: 'gpt-after-rewind', + effort: 'high' + }) } ) From 03bc33464b7e5bab9702c9e94b24307e6a350b3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:27:18 -0700 Subject: [PATCH 20/31] Cover goal changes during the held Codex message wait [skip ci] --- ...ured-agent-session-codex-held-goal.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts 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)) +}) From 59267e1c1ce77705cb6ca6bf45806211688496fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:50:04 -0700 Subject: [PATCH 21/31] Check the saved picker selection in per-message choice fixtures --- ...uctured-startup-unanswered-control-request.test.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) 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 2cba0bdb2c0f..6466c0507b58 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,16 +171,11 @@ 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 shows the child running the CLI's own model: the picker follows it, the record - // keeps what the user chose. + // A live report describes the turn; the picker keeps the saved next-message choice. turnReportsModel('claude-sonnet-5') - expect(await picker(host)).toEqual({ - model: 'claude-sonnet-5', - effort: 'high', - confirmed: ['model', 'effort'] - }) + expect(await picker(host)).toEqual({ model: 'sonnet', effort: undefined, confirmed: undefined }) expect(record(host)?.options).toEqual({ model: 'sonnet' }) behavior.optionWritesHang = false From bbe11a97321055f069ae1c692d43ee16fa6993e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:28:32 -0700 Subject: [PATCH 22/31] Exercise current choices and eager client capabilities in runtime fixtures --- ...-session-codex-turn-end-settlement.test.ts | 27 ++++--- ...ructured-agent-session-integration.test.ts | 79 +++++++++++-------- 2 files changed, 63 insertions(+), 43 deletions(-) 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..65f26c0b0a2e 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) { @@ -363,10 +366,11 @@ 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 () => { + it('keeps the turn identity of a steering send after the opening message is identified', async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() + turns.echo(opening) const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) @@ -377,9 +381,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, @@ -558,12 +560,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 +570,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 +587,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) }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 1696472ef8a9..19d3997834b3 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` ──────────────────────────────────────────── @@ -108,11 +105,19 @@ function fakeCodex(): CodexScript { request: async (method, params) => { connection.calls.push({ method, params }) if (method === 'thread/start') { - return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + return { + thread: { id: THREAD, path: '/rollouts/integration.jsonl' }, + model: 'gpt-live', + reasoningEffort: 'medium' + } } if (method === 'thread/resume') { connection.resumedThreadId = (params as { threadId: string }).threadId - return { thread: { id: connection.resumedThreadId } } + return { + thread: { id: connection.resumedThreadId }, + model: 'gpt-live', + reasoningEffort: 'medium' + } } if (method === 'turn/start') { return { turn: { id: TURN } } @@ -336,7 +341,7 @@ beforeEach(async () => { providerHandle: _providerHandle, ...resolved } = attachParams(null) - return resolved + return { ...resolved, options: { model: 'gpt-live' } } }, publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => @@ -442,13 +447,15 @@ 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 vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + ) }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -534,7 +541,11 @@ describe('a structured codex session over agentSession.*', () => { expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await call('agentSession.options', { sessionId: SESSION }) + const options = await vi.waitFor(async () => { + const result = await call('agentSession.options', { sessionId: SESSION }) + expect(result).toMatchObject({ ok: true, result: { current: { model: 'gpt-live' } } }) + return result + }) expect(options).toMatchObject({ ok: true, result: { @@ -552,10 +563,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 ──────────────────────────────────────────────────────────────── @@ -697,13 +710,15 @@ 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({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live', effort: 'high' } - } - }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live', effort: 'high' } + } + }) + ) // The journal belongs to the session, not to the process that just died. expect(resumed.page.items.map(textOf)).toContain('Two files.') @@ -727,7 +742,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', From ce99d7a8ddcca5c9fb10abde62adc10ba4266c28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:52:37 -0700 Subject: [PATCH 23/31] Reuse runtime fixture reads and type the fake connection directly --- ...-session-codex-turn-end-settlement.test.ts | 50 +++++--------- ...ructured-agent-session-integration.test.ts | 66 +++++++------------ 2 files changed, 39 insertions(+), 77 deletions(-) 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 65f26c0b0a2e..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 @@ -302,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') @@ -327,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)) @@ -367,10 +372,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { } it('keeps the turn identity of a steering send after the opening message is identified', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) @@ -390,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 () => @@ -444,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)) @@ -491,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)) @@ -512,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() @@ -869,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 @@ -889,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 19d3997834b3..680e51150ba4 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -93,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, @@ -105,19 +105,11 @@ function fakeCodex(): CodexScript { request: async (method, params) => { connection.calls.push({ method, params }) if (method === 'thread/start') { - return { - thread: { id: THREAD, path: '/rollouts/integration.jsonl' }, - model: 'gpt-live', - reasoningEffort: 'medium' - } + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } } if (method === 'thread/resume') { connection.resumedThreadId = (params as { threadId: string }).threadId - return { - thread: { id: connection.resumedThreadId }, - model: 'gpt-live', - reasoningEffort: 'medium' - } + return { thread: { id: connection.resumedThreadId } } } if (method === 'turn/start') { return { turn: { id: TURN } } @@ -152,7 +144,7 @@ function fakeCodex(): CodexScript { } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } const live = (): FakeConnection => { const connection = connections.at(-1) if (!connection) { @@ -323,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-')) @@ -447,15 +453,7 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - await vi.waitFor(async () => - 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 () => { @@ -541,18 +539,7 @@ describe('a structured codex session over agentSession.*', () => { expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await vi.waitFor(async () => { - const result = await call('agentSession.options', { sessionId: SESSION }) - expect(result).toMatchObject({ ok: true, result: { current: { model: 'gpt-live' } } }) - return result - }) - 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', @@ -710,15 +697,10 @@ describe('a structured codex session over agentSession.*', () => { expect(resumed.fence).toBe(fence + 1) expect(reaped.closed).toBe(true) expect(codex.live().resumedThreadId).toBe(THREAD) - await vi.waitFor(async () => - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live', effort: 'high' } - } - }) - ) + expect(await readKnownOptions()).toMatchObject({ + ok: true, + 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.') From 7364de75985aa256c5a81ef97eec8a5494b1e5b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:48:18 -0700 Subject: [PATCH 24/31] Honor queued send authorization in Claude runtime fixtures --- .../runtime/claude-structured-fake-connection-test-fixture.ts | 3 ++- src/main/runtime/claude-structured-session-integration.test.ts | 3 ++- .../runtime/structured-claude-scripted-runtime-test-support.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) 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 2e4009ed7e42..7fbc53248df7 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -534,7 +534,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/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 () => { From 35ee605c1dd74b9e06ad498af782e7c9c4a2ade1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:04:33 -0700 Subject: [PATCH 25/31] Keep repeated permission picks usable and align Codex fixtures --- ...nt-session-codex-opening-send-hold.test.ts | 20 ++++++++++++------- .../structured-child-records-switch.test.ts | 13 +++++++++++- .../use-structured-agent-session-options.ts | 3 --- .../structured-agent-session-options.ts | 3 ++- 4 files changed, 27 insertions(+), 12 deletions(-) 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-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/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 1f089317b151..c467f7ed1f22 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 @@ -256,9 +256,6 @@ export function useStructuredAgentSessionOptions(args: { void applied?.then(settleLaunchOptionPick) return applied === null ? null : { [id]: encoded } } - if (optionStateRef.current.pendingPick !== null) { - return null - } return sendStructuredOption(id, encoded) }, [ diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index e1c739c7bb79..7b13519b3aa5 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -219,7 +219,8 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingPick === null && + (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)) || From 466dc12cf124f0e477095dd0096b4fb1b0afa389 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:26:33 -0700 Subject: [PATCH 26/31] Validate pending picks against the live option state --- .../native-chat/use-structured-agent-session-options.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 c467f7ed1f22..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 @@ -246,7 +246,11 @@ export function useStructuredAgentSessionOptions(args: { if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(optionView, id, value) + !canSetStructuredAgentSessionOption( + { ...optionView, pendingPick: optionStateRef.current.pendingPick }, + id, + value + ) ) { return null } From af5edab90ea7ea2eaaa8b06d5b2b8c148ac5b5be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:57:46 -0700 Subject: [PATCH 27/31] Keep message choice flows and remove overlapping selector tests --- .../acp-structured-session-adapter.test.ts | 87 ------ .../claude-structured-message-options.test.ts | 26 -- .../codex-structured-message-options.test.ts | 239 ----------------- ...ured-agent-session-message-choices.test.ts | 15 -- ...agent-session-options-cold-catalog.test.ts | 137 ---------- src/main/pi/rpc-session-adapter.test.ts | 113 -------- ...gent-session-options.cold-catalog.test.tsx | 109 -------- ...session-options.current-selection.test.tsx | 173 ------------ ...session-options.no-catalog-effort.test.tsx | 104 -------- ...t-session-options.pending-restart.test.tsx | 98 ------- ...nt-session-options.scoped-default.test.tsx | 148 ----------- ...se-structured-agent-session-sends.test.tsx | 64 +---- ...-session-model-catalog-application.test.ts | 248 ------------------ 13 files changed, 1 insertion(+), 1560 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx delete mode 100644 src/shared/structured-agent-session-model-catalog-application.test.ts diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index a6070fa3356f..b470ab562c36 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -180,93 +180,6 @@ describe('ACP structured session adapter: turns', () => { }) }) - it('restores the opened model and effort when the next message omits both choices', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'override', - body: hello, - executionOptions: { model: 'grok-4.6', effort: 'low' } - }) - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'default', - body: hello, - executionOptions: {} - }) - expect(rig.sent('session/set_config_option')).toMatchObject([ - { params: { configId: 'model', value: 'grok-4.6' } }, - { params: { configId: 'reasoning_effort', value: 'low' } }, - { params: { configId: 'model', value: 'grok-4.7' } }, - { params: { configId: 'reasoning_effort', value: 'high' } } - ]) - }) - - it('keeps legacy sends on the current selection when execution options are absent', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'override', - body: hello, - executionOptions: { model: 'grok-4.6', effort: 'low' } - }) - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'legacy', - body: hello - }) - expect(rig.sent('session/set_config_option')).toHaveLength(2) - expect(rig.sent('session/prompt')).toHaveLength(1) - }) - - it('refuses an omitted effort whose new model default cannot be confirmed', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'override-effort', - body: hello, - executionOptions: { model: 'grok-4.7', effort: 'low' } - }) - await expect( - rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'unknown-default', - body: hello, - executionOptions: { model: 'grok-4.6' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(rig.sent('session/prompt')).toHaveLength(1) - }) - - it('does not send a prompt when the agent refuses a message’s model', async () => { - const rig = await openAcpAdapterRig({ - script: (agent) => - agent.on('session/set_config_option', (frame) => - agent.fail(frame, -32602, 'Model unavailable') - ) - }) - await rig.acquire() - await expect( - rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'refused-model', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - executionOptions: { model: 'grok-4.6' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(rig.sent('session/prompt')).toEqual([]) - }) - 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/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 7366be3bb1cd..2cc53747affb 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { ClaudeControlRequestError } from './claude-stream-json-connection' import { acquired, claudeFrame, @@ -147,29 +146,4 @@ describe('Claude accepted message choices', () => { ]) expect(connection.sent).toHaveLength(2) }) - - it('rejects a refused model before any prompt is handed to Claude', async () => { - const claude = fakeClaude({ - routes: { - list_models: () => MODELS, - set_model: () => { - throw new ClaudeControlRequestError('set_model', 'model unavailable') - } - } - }) - const adapter = await acquired(claude) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'refused', - body: USER_MESSAGE, - executionOptions: { model: 'opus', effort: 'low' }, - fence: 7 - }) - ).resolves.toMatchObject({ - state: 'rejected', - rejection: { kind: 'providerRejected' } - }) - expect(claude.connections[0]!.sent).toHaveLength(0) - }) }) diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 3f35777c4848..6bef482395ea 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { agentSessionExecutionOptionsFromSnapshot } from '../../shared/agent-session-execution-option-snapshot' -import { - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' import { optionSession } from './codex-structured-option-session.test-fixture' import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { startCodexTurn } from './codex-structured-turn-start' @@ -145,103 +137,6 @@ describe('Codex captured message choices', () => { }) }) - it('resets to the configured model after opening the thread with an explicit model', async () => { - const store = new AgentModelCatalogStore() - const access = agentModelCatalogSessionAccess(store, CODEX_STRUCTURED_AGENT, '/test-account') - if (!access) { - throw new Error('expected Codex catalog access') - } - store.recordSuccess( - access.fingerprint, - 'codex', - { - origin: 'probe', - models: [ - { - id: 'model-a', - label: 'Model A', - isDefault: false, - defaultEffort: 'high', - efforts: [{ value: 'high', label: 'High' }] - }, - { - id: 'model-b', - label: 'Model B', - isDefault: true, - defaultEffort: 'medium', - efforts: [{ value: 'medium', label: 'Medium' }] - } - ] - }, - 'discovery' - ) - const codex = fakeCodex({ - 'thread/start': () => ({ - thread: { id: 'thread-abc' }, - model: 'model-a', - reasoningEffort: 'high' - }), - 'model/list': () => ({ - data: [ - { - model: 'model-a', - supportedReasoningEfforts: [{ reasoningEffort: 'high' }], - defaultReasoningEffort: 'high' - }, - { - model: 'model-b', - supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], - defaultReasoningEffort: 'medium' - } - ], - nextCursor: null - }), - 'config/read': () => ({ - config: { model: 'model-b', model_reasoning_effort: 'medium' } - }), - 'turn/start': () => ({ turn: { id: 'opened' } }) - }) - const adapter = adapterFor(codex, { model: 'model-a', codexHome: '/test-account' }, [], { - modelCatalog: store - }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-1', - options: { model: 'model-a', effort: 'high' } - }) - expect( - codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params - ).toMatchObject({ - model: 'model-a' - }) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'a', - body: USER_MESSAGE, - executionOptions: { model: 'model-a', effort: 'high' }, - fence: 7 - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'b', - body: USER_MESSAGE, - executionOptions: {}, - fence: 7 - }) - expect( - codex.connections[0]?.calls - .filter((call) => call.method === 'turn/start') - .map((call) => call.params) - ).toMatchObject([ - { model: 'model-a', effort: 'high' }, - { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' } - ]) - expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('model/list') - expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('config/read') - }) - 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' }) @@ -266,34 +161,6 @@ describe('Codex captured message choices', () => { ).toMatchObject({ model: 'model-a', effort: 'high' }) }) - it('uses native defaults through a held catalog read and allows the child to close', async () => { - const codex = fakeCodex({ - 'model/list': () => new Promise(() => {}), - 'turn/start': () => ({ turn: { id: 'opened' } }) - }) - const adapter = adapterFor(codex, { model: 'newer-saved-model' }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-1', - options: {} - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'default', - body: USER_MESSAGE, - executionOptions: {}, - fence: 7 - }) - expect( - codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params - ).not.toHaveProperty('model') - expect( - codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params - ).not.toHaveProperty('model') - expect(await adapter.closeSession('session-1')).toBe(true) - }) - 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' } @@ -310,90 +177,6 @@ describe('Codex captured message choices', () => { expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) }) - it('uses a saved Codex choice when the picker has not loaded', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'saved-turn' } - })) - const session = optionSession(request) - session.options.set('model', 'gpt-saved-unlisted') - session.options.set('effort', 'high') - session.reportedOptions = { model: 'gpt-saved-unlisted', effort: 'high' } - delete session.catalogAccess - delete session.nativeDefaults - const seed = structuredAgentSessionSeedCatalog('codex') - const snapshot = structuredAgentSessionOptionSnapshot( - createStructuredAgentSessionOptionState('codex', seed) - ) - const captured = agentSessionExecutionOptionsFromSnapshot(snapshot) - expect(captured).toBeUndefined() - const turnOptions = await resolvedCodexMessageOptions(session, captured) - await startCodexTurn(session, { - clientMessageId: 'saved', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, - turnOptions - }) - expect(request.mock.calls[0]?.[1]).toMatchObject({ - model: 'gpt-saved-unlisted', - effort: 'high' - }) - }) - - it('sends a fresh default while model discovery is held', async () => { - const request = vi.fn(async (method: string, _params?: Record) => { - if (method === 'model/list') { - return new Promise(() => {}) - } - return { turn: { id: 'default-turn' } } - }) - const session = optionSession(request) - session.reportedOptions = { model: 'model-a', effort: 'high' } - session.nativeDefaults = { model: 'model-a', effort: 'high' } - const turnOptions = await resolvedCodexMessageOptions(session, {}) - await startCodexTurn(session, { - clientMessageId: 'default', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, - turnOptions - }) - expect(request.mock.calls.map(([method]) => method)).toEqual(['turn/start']) - expect(request.mock.calls[0]?.[1]).toMatchObject({ - personality: 'none', - serviceTier: 'default' - }) - expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model') - expect(request.mock.calls[0]?.[1]).not.toHaveProperty('effort') - }) - - it('restores opened native defaults after an explicit override without a catalog', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'opened' } - })) - const session = optionSession(request) - session.reportedOptions = { model: 'native-model', effort: 'medium' } - session.nativeDefaults = { model: 'native-model', effort: 'medium' } - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'go' }] - } - await startCodexTurn(session, { - clientMessageId: 'override', - body, - turnOptions: await resolvedCodexMessageOptions(session, { - model: 'other-model', - effort: 'high' - }) - }) - await startCodexTurn(session, { - clientMessageId: 'reset', - body, - turnOptions: await resolvedCodexMessageOptions(session, {}) - }) - expect(request.mock.calls.map(([, params]) => params)).toMatchObject([ - { model: 'other-model', effort: 'high' }, - { model: 'native-model', effort: 'medium', personality: 'none', serviceTier: 'default' } - ]) - }) - 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) @@ -436,26 +219,4 @@ describe('Codex captured message choices', () => { ).rejects.toThrow('connection lost after write') expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' }) }) - - it('refuses an unavailable message option before a protocol prompt', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'turn-1' } - })) - const session = optionSession(request) - seedMessageDefaults(session) - await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( - 'does not support Fast mode' - ) - expect(request).not.toHaveBeenCalled() - }) - - it('refuses an unlisted captured model before prompt input', async () => { - const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) - const session = optionSession(request) - seedMessageDefaults(session) - await expect( - resolvedCodexMessageOptions(session, { model: 'unlisted', effort: 'high' }) - ).rejects.toThrow('does not offer model unlisted') - expect(request).not.toHaveBeenCalled() - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 5669ea0ae03f..f6df02978f8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -49,21 +49,6 @@ it('keeps the first accepted choice through opening and saves a later pick for t expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') }) -it('queues a different choice during a running Codex turn', async () => { - await pickModel('model-a') - const first = await rig.workingSend() - await pickModel('model-b') - const second = rig.send('second') - expect(await second.result).toMatchObject({ ok: true }) - expect(rig.dispatch).toHaveBeenCalledTimes(1) - await rig.settleAccepted(first, 'a') - await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions?.model)).toEqual([ - 'model-a', - 'model-b' - ]) -}) - it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { await pickModel('model-b') rig.dispatch.mockRejectedValueOnce( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts deleted file mode 100644 index a295677b8038..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { structuredAgentSessionSeedCatalog } from '../../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - applyStructuredAgentSessionOptions, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../../shared/structured-agent-session-options' -import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' -import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { PI_RPC_AGENT } from '../../pi/rpc-agent-definition' -import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' -import { - AgentModelCatalogStore, - type AgentModelCatalogSuccess -} from '../agent-model-catalog/agent-model-catalog-store' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' - -const DEFINITIONS = [ - CODEX_STRUCTURED_AGENT, - acpStructuredAgentDefinition(acpLaunchSpecFor('grok')!), - PI_RPC_AGENT, - CLAUDE_STRUCTURED_AGENT -] -const MODELS = [ - { - id: 'configured-model', - label: 'Configured model', - isDefault: true, - defaultEffort: 'high', - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - } -] - -function coldChat(definition: (typeof DEFINITIONS)[number], starting: boolean) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are all the record fields the options read and catalog fingerprint access. - const record = { - provider: definition.agent, - accountHome: { variable: 'TEST_AGENT_HOME', path: '/test/account' }, - location: { executionHostId: 'local', wslDistro: null }, - options: {} - } as unknown as AgentSessionRecord - const listing = Promise.withResolvers() - const probe = vi.fn(() => listing.promise) - const modelCatalog = createAgentModelCatalogService({ - store: new AgentModelCatalogStore(), - getRecord: () => record, - drivesRecord: () => true, - resolveAccountHome: async () => record.accountHome, - recordWorkspacePath: async () => '/test/folder', - workspaceMayOverrideDefaultModel: async () => false, - listingNamesConfiguredModel: new Set([definition.agent]), - probes: { [definition.agent]: probe } - }) - const resting = { - child: starting ? { phase: 'starting' } : null, - params: { provider: definition.agent }, - journal: { context: { floor: () => null } } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting path accesses only these context members; no provider process runs. - const context = { - deps: { - adapter: {}, - agents: { definition: () => definition, capabilities: () => null }, - store: { getRecord: () => record }, - modelCatalog - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - const read = (sessionId = 'first-chat') => - readStructuredAgentSessionOptions(context, sessionId, { readsWithoutModel: true }) - return { listing, probe, modelCatalog, read } -} - -describe.each(DEFINITIONS)('$agent first options read before its catalog loads', (definition) => { - it.each([false, true])('fills the picker in place with starting=%s', async (starting) => { - const chat = coldChat(definition, starting) - const agent = definition.agent - const seed = structuredAgentSessionSeedCatalog(agent) - let state = createStructuredAgentSessionOptionState(agent, seed) - const pending = await chat.modelCatalog.read({ agent, sessionId: 'first-chat' }) - expect(pending).toMatchObject({ origin: 'unknown', listingInProgress: true }) - const waiting = chat.modelCatalog.read({ agent, sessionId: 'first-chat', waitForListing: true }) - const options = await chat.read() - expect(options.current.model).toBeUndefined() - state = applyStructuredAgentSessionOptions(state, seed, options) - const beforeListing = structuredAgentSessionOptionSnapshot(state) - chat.listing.resolve({ origin: 'probe', models: MODELS }) - const loaded = await waiting - state = applyStructuredAgentSessionModelCatalog(state, seed, loaded, { newLaunch: true }) - expect(structuredAgentSessionOptionSnapshot(state)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: 'configured-model' }) - }), - expect.objectContaining({ - id: 'effort', - kind: expect.objectContaining({ currentValue: 'high' }) - }) - ]) - ) - expect(beforeListing).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) - ]) - ) - expect(chat.probe).toHaveBeenCalledOnce() - - const secondOptions = await chat.read('second-chat') - const second = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState(agent, seed), - seed, - secondOptions - ) - expect(secondOptions.current).toMatchObject({ model: 'configured-model', effort: 'high' }) - expect(structuredAgentSessionOptionSnapshot(second)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: 'configured-model' }) - }) - ]) - ) - expect(chat.probe).toHaveBeenCalledOnce() - chat.modelCatalog.stop() - }) -}) diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 44f4b1fb7764..75f7966f139d 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -168,119 +168,6 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) }) - it('restores the opened model when a later snapshot omits its override', async () => { - const h = await setup() - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'override', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'default', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, - executionOptions: {} - }) - expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ - 'set_model', - 'set_model' - ]) - expect(h.connection.sent).toMatchObject([ - { type: 'prompt', message: 'first' }, - { type: 'prompt', message: 'second' } - ]) - }) - - it('keeps legacy sends on the current selection when execution options are absent', async () => { - const h = await setup() - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'override', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'legacy', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] } - }) - expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ - 'set_model' - ]) - expect(h.connection.sent).toHaveLength(2) - }) - - it('refuses an omitted effort whose new model default cannot be confirmed', async () => { - const h = await setup() - h.connection.requestOverride = (command) => - command === 'set_thinking_level' ? Promise.resolve({}) : undefined - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'override-effort', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, - executionOptions: { model: 'anthropic/model-1', effort: 'low' } - }) - await expect( - h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'unknown-default', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(h.connection.sent).toHaveLength(1) - }) - - it('applies the displayed default model and effort after different message choices', async () => { - const h = await setup() - h.connection.requestOverride = (command) => - command === 'set_thinking_level' ? Promise.resolve({}) : undefined - for (const [clientMessageId, model, effort] of [ - ['override', 'anthropic/model-2', 'low'], - ['default', 'anthropic/model-1', 'high'] - ]) { - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - executionOptions: { model, effort } - }) - } - expect(h.connection.requests.slice(-4)).toEqual([ - 'set_model', - 'set_thinking_level', - 'set_model', - 'set_thinking_level' - ]) - expect(h.connection.sent).toHaveLength(2) - }) - - it('leaves the prompt unsent when Pi refuses a sent model', async () => { - const h = await setup() - h.connection.requestOverride = (command) => - command === 'set_model' - ? Promise.reject(new JsonlRpcResponseError(command, 'Model unavailable')) - : undefined - await expect( - h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'refused-choice', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(h.connection.sent).toEqual([]) - }) - 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/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx deleted file mode 100644 index 168b574fb32f..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// @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() })) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call -})) - -import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' -import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' - -const LOCAL = { kind: 'local' } as const -const EMPTY_SELECTION = {} -const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } -const MODELS = [ - { - id: 'configured-model', - label: 'Configured model', - isDefault: true, - defaultEffort: 'high', - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - } -] -const LOADED: AgentSessionModelCatalogResult = { - origin: 'probe', - models: MODELS, - fetchedAt: 1, - listingNamesConfiguredModel: true -} - -function renderChat(agent: string, selectionFromStatus: boolean, sessionId = 'first-chat') { - const frames: string[][] = [] - const view = renderHook(() => { - const options = useStructuredAgentSessionOptions({ - agent, - sessionId, - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - ...(selectionFromStatus ? { optionSelection: EMPTY_SELECTION } : {}), - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate: async () => null, - launch: LAUNCH - }) - frames.push(options.optionSnapshot.map((option) => option.id)) - return options - }) - return { ...view, frames } -} - -beforeEach(() => { - mocks.call.mockReset() - resetHostModelCatalogSnapshotsForTests() -}) - -it.each(['codex', 'grok', 'pi', 'claude'])( - '%s keeps its picker when options answer before the first listing, with either selection source', - async (agent) => { - for (const selectionFromStatus of [false, true]) { - resetHostModelCatalogSnapshotsForTests() - const listing = Promise.withResolvers() - mocks.call.mockImplementation( - async (_target: unknown, method: string, params: { waitForListing?: boolean }) => - method === 'agentSession.modelCatalog' - ? params.waitForListing - ? listing.promise - : { origin: 'unknown', listingInProgress: true } - : { models: [], modelsKnown: false, current: {}, conversationCommands: ['clear'] } - ) - const view = renderChat(agent, selectionFromStatus) - await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) - expect(view.frames.every((frame) => frame.includes('model'))).toBe(true) - await act(async () => listing.resolve(LOADED)) - await waitFor(() => { - const snapshot = view.result.current.optionSnapshot - expect(snapshot.find((option) => option.id === 'model')?.kind).toMatchObject({ - currentValue: 'configured-model' - }) - expect(snapshot.find((option) => option.id === 'effort')?.kind).toMatchObject({ - currentValue: 'high' - }) - }) - view.unmount() - - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? LOADED - : { models: MODELS, current: { model: 'configured-model', effort: 'high' } } - ) - const second = renderChat(agent, selectionFromStatus, 'second-chat') - expect( - second.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind - ).toMatchObject({ currentValue: 'configured-model' }) - await waitFor(() => expect(second.result.current.rewind).toBeDefined()) - expect(second.frames.every((frame) => frame.includes('model'))).toBe(true) - second.unmount() - } - } -) 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 index d1f48321e4a3..2d1957bca36b 100644 --- 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 @@ -141,50 +141,6 @@ it('uses the existing options read when an older host omits the selection field' view.unmount() }) -it('retires an accepted pick on reply even when another host change arrived first', async () => { - let selection: Readonly> = { model: 'gpt-one' } - mocks.summary.mockImplementation(() => ({ optionSelection: selection })) - 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 launch = { kind: 'new' as const, heldOptions: {} } - const view = renderHook( - (sessionId: string) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId, - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch - }), - { initialProps: 'session-1' } - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let request!: Promise - act(() => { - request = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - selection = { model: 'gpt-two' } - view.rerender('session-1') - selection = { model: 'gpt-one' } - view.rerender('session-1') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - await act(async () => { - write.resolve({ options: { model: 'gpt-two' } }) - await request - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - 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 }>() @@ -223,135 +179,6 @@ it('admits one pending pick, including after the picker re-renders', async () => view.unmount() }) -it('drops refused and previous-chat picks without overriding current host selection', async () => { - let selection: Readonly> = { model: 'gpt-one' } - mocks.summary.mockImplementation(() => ({ optionSelection: selection })) - const refused = deferred() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: null is a refused mutation reply. - const mutate = vi.fn(() => refused.promise) as unknown as StructuredAgentSessionMutate - const launch = { kind: 'new' as const, heldOptions: {} } - const view = renderHook( - (sessionId: string) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId, - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch - }), - { initialProps: 'session-1' } - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let request!: Promise - act(() => { - request = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - view.rerender('session-2') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - await act(async () => { - refused.resolve(null) - await request - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - view.rerender('session-1') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - selection = { model: 'gpt-two' } - view.rerender('session-2') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - view.unmount() -}) - -it('shows the value the host accepted when it normalizes a pick', async () => { - let selection: Readonly> = { model: 'gpt-one' } - mocks.summary.mockImplementation(() => ({ optionSelection: selection })) - const write = deferred<{ options: Record }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this 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 request!: Promise<{ snapshot: readonly SessionOptionDescriptor[] }> - act(() => { - request = view.result.current.optionSurface.setOption('model', 'gpt-two') - }) - selection = { model: 'gpt-one' } - view.rerender() - let returned!: { snapshot: readonly SessionOptionDescriptor[] } - await act(async () => { - write.resolve({ options: { model: 'gpt-one' } }) - returned = await request - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - expect(pill(returned.snapshot).model).toBe('gpt-one') - expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { - type: 'apply-picks', - agent: 'codex', - picks: [{ modelId: 'gpt-one', optionId: 'model', value: 'gpt-one' }] - }) - view.unmount() -}) - -it('binds an effort-only pick and its returned snapshot to the host-selected model', async () => { - mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-two', effort: 'low' } })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this hook reads. - const mutate = vi.fn(async () => ({ - options: { effort: 'high' } - })) as unknown as StructuredAgentSessionMutate - const launch = { kind: 'new' as const, heldOptions: {} } - 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 - }) - ) - await waitFor(() => - expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-two', effort: 'low' }) - ) - let returned: { snapshot: readonly SessionOptionDescriptor[] } | undefined - await act(async () => { - returned = await view.result.current.optionSurface.setOption('effort', 'high') - }) - expect(pill(returned?.snapshot ?? [])).toEqual({ model: 'gpt-two', effort: 'high' }) - expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { - type: 'apply-picks', - agent: 'codex', - picks: [{ modelId: 'gpt-two', optionId: 'effort', value: 'high' }] - }) - 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 }>() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx deleted file mode 100644 index d2cefeab9804..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// @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() })) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call -})) - -import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' -import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' - -const LOCAL = { kind: 'local' } as const -const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } - -function selection(snapshot: readonly SessionOptionDescriptor[]) { - const value = (id: string) => { - const option = snapshot.find((entry) => entry.id === id) - return option?.kind.type === 'select' ? option.kind.currentValue : undefined - } - return { model: value('model'), effort: value('effort') } -} - -beforeEach(() => { - mocks.call.mockReset() - resetHostModelCatalogSnapshotsForTests() -}) - -it.each([ - ['claude', 'opus'], - ['codex', 'gpt-5.5'] -])('%s keeps a saved effort through start before its catalog loads', async (agent, model) => { - for (const selectionFromStatus of [false, true]) { - resetHostModelCatalogSnapshotsForTests() - const saved = { model, effort: 'high' } - const listing = Promise.withResolvers() - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? listing.promise - : { models: [], modelsKnown: false, current: saved, conversationCommands: ['clear'] } - ) - const frames: ReturnType[] = [] - const view = renderHook( - ({ fence, starting }: { fence: number; starting: boolean }) => { - const options = useStructuredAgentSessionOptions({ - agent, - sessionId: 'saved-chat', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - providerStarting: starting, - ...(selectionFromStatus ? { optionSelection: saved } : {}), - fence, - turnId: null, - unloadedTurnRevisions: undefined, - mutate: async () => null, - launch: LAUNCH - }) - frames.push(selection(options.optionSnapshot)) - return options - }, - { initialProps: { fence: 1, starting: false } } - ) - await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) - expect(selection(view.result.current.optionSnapshot)).toEqual(saved) - const beforeStart = frames.length - view.rerender({ fence: 2, starting: true }) - expect(selection(view.result.current.optionSnapshot)).toEqual(saved) - view.rerender({ fence: 2, starting: false }) - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - expect( - frames.slice(beforeStart).every((frame) => frame.model === model && frame.effort === 'high') - ).toBe(true) - await act(async () => - listing.resolve({ - origin: 'probe', - models: [ - { - id: model, - label: 'Host model', - isDefault: false, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - } - ], - fetchedAt: 1 - }) - ) - await waitFor(() => - expect( - view.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind - ).toMatchObject({ choices: [{ value: model, label: 'Host model' }] }) - ) - expect(selection(view.result.current.optionSnapshot)).toEqual(saved) - view.unmount() - } -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx deleted file mode 100644 index 31f5c7cc90e9..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @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(), 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 -})) - -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 LOCAL = { kind: 'local' } as const -const LAUNCH = { kind: 'new' as const, heldOptions: {} } -const SELECTION = { model: 'gpt-one' } -const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ id, label: id, efforts: [] })) - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => (resolve = settle)) - return { promise, resolve } -} - -beforeEach(() => { - resetHostModelCatalogSnapshotsForTests() - mocks.settings.mockReset() - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? { origin: 'live-session', models: MODELS, fetchedAt: 1 } - : { models: MODELS, current: {} } - ) -}) - -it.each([false, true])( - 'admits a later pick after restart when the earlier reply %s arrives', - async (settlesLate) => { - const abandoned = deferred<{ options: Record }>() - const latest = deferred() - const send = vi.fn().mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(latest.promise) - const mutate: StructuredAgentSessionMutate = send - const initialFence: number | null = 1 - const view = renderHook, number | null>( - (fence: number | null) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: SELECTION, - fence, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch: LAUNCH - }), - { initialProps: initialFence } - ) - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - let first!: Promise - act(() => { - first = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(send).toHaveBeenCalledOnce() - view.rerender(null) - view.rerender(2) - let second!: Promise - act(() => { - second = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(send).toHaveBeenCalledTimes(2) - if (settlesLate) { - await act(async () => { - abandoned.resolve({ options: { model: 'gpt-two' } }) - expect(await first).toBe(false) - }) - expect(mocks.settings).not.toHaveBeenCalled() - await act(async () => { - expect(await view.result.current.setStructuredOption('model', 'gpt-one')).toBe(false) - }) - expect(send).toHaveBeenCalledTimes(2) - } - await act(async () => { - latest.resolve(null) - expect(await second).toBe(false) - }) - const model = view.result.current.optionSnapshot.find((option) => option.id === 'model') - expect(model?.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-one') - view.unmount() - } -) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx deleted file mode 100644 index bf6aa9e11597..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -// @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() })) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call -})) - -import type { - AgentSessionModelCatalogResult, - AgentSessionOptionsResult -} from '../../../../shared/agent-session-wire' -import { - forgetHostModelCatalogSnapshots, - recordHostModelCatalogSnapshot, - 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 LOCAL = { kind: 'local' } as const -const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ - id, - label: id, - isDefault: false, - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] -})) -const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } -const EMPTY_SELECTION = {} -const NO_MUTATE: StructuredAgentSessionMutate = async () => null - -function catalog(defaultModel = 'gpt-one'): AgentSessionModelCatalogResult { - return { - origin: 'live-session', - models: MODELS.map((model) => ({ - ...model, - isDefault: model.id === defaultModel, - ...(model.id === defaultModel ? { defaultEffort: 'medium' } : {}) - })), - fetchedAt: 1, - listingNamesConfiguredModel: true - } -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => (resolve = settle)) - return { promise, resolve } -} - -function renderChat() { - return renderHook( - (fence: number) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: EMPTY_SELECTION, - fence, - turnId: null, - unloadedTurnRevisions: undefined, - mutate: NO_MUTATE, - launch: LAUNCH - }), - { initialProps: 1 } - ) -} - -function selected(view: ReturnType) { - return Object.fromEntries( - view.result.current.optionSnapshot.map((option) => [ - option.id, - option.kind.type === 'select' ? (option.kind.currentValue ?? null) : null - ]) - ) -} - -beforeEach(() => { - mocks.call.mockReset() - resetHostModelCatalogSnapshotsForTests() -}) - -it.each(['catalog-first', 'options-first'] as const)( - 'uses the scoped default with an empty host selection after %s replies', - async (order) => { - const listing = deferred() - const options = deferred() - const metadata: AgentSessionOptionsResult = { - models: MODELS, - current: { model: 'gpt-two', effort: 'high' }, - conversationCommands: ['clear'] - } - mocks.call.mockImplementation((_target: unknown, method: string) => - method === 'agentSession.modelCatalog' ? listing.promise : options.promise - ) - const view = renderChat() - await act(async () => { - if (order === 'catalog-first') { - listing.resolve(catalog()) - } else { - options.resolve(metadata) - } - }) - await act(async () => { - listing.resolve(catalog()) - options.resolve(metadata) - }) - await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) - expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) - view.unmount() - } -) - -it.each(['folder config', 'account'] as const)( - 'refreshes an empty selection default after the %s changes', - async (change) => { - recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog()) - let defaultModel = 'gpt-one' - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? catalog(defaultModel) - : { models: MODELS, current: {} } - ) - const view = renderChat() - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) - defaultModel = 'gpt-two' - if (change === 'account') { - forgetHostModelCatalogSnapshots(LOCAL, ['codex']) - } - recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog(defaultModel)) - view.rerender(1) - expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) - view.rerender(2) - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) - view.unmount() - } -) 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 0b4b18dbf371..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 @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, renderHook } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -11,11 +11,6 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' -import { - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../../../shared/structured-agent-session-options' -import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' const QUEUEING = { capability: 'supported', enabled: true } as const @@ -24,63 +19,6 @@ afterEach(() => { mocks.call.mockReset() }) -it('omits choices when Send precedes the picker’s model answer', async () => { - mocks.call.mockImplementation(() => new Promise(() => {})) - const snapshot = structuredAgentSessionOptionSnapshot( - createStructuredAgentSessionOptionState('codex', structuredAgentSessionSeedCatalog('codex')) - ) - const { result } = renderHook(() => - useStructuredAgentSessionSends({ - sessionId: 'session-1', - target: { kind: 'local' }, - fence: 1, - submissions: [], - queuedMessageIds: [], - queue: QUEUEING, - historyLoaded: false, - executionOptionSnapshot: snapshot - }) - ) - act(() => { - result.current.send('before picker answer') - }) - await waitFor(() => - expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.send')).toBe(true) - ) - const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.send') - expect(call?.[2]).not.toHaveProperty('executionOptions') -}) - -// 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/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 ac395c67752b..000000000000 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ /dev/null @@ -1,248 +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') - }) - - it('keeps a known catalog and default when an options read has no loaded catalog', () => { - const hosted = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - HOST_CATALOG, - LAUNCH - ) - const unknown = applyStructuredAgentSessionOptions(hosted, SEED, { - models: [], - modelsKnown: false, - current: {} - }) - expect(unknown.catalog).toBe(hosted.catalog) - expect(unknown.catalogSource).toBe('host') - expect(structuredAgentSessionOptionSnapshot(unknown)).toEqual( - structuredAgentSessionOptionSnapshot(hosted) - ) - const picked = applyStructuredAgentSessionOptions(unknown, SEED, { - models: [], - modelsKnown: false, - current: { model: 'gpt-hosted', effort: 'medium' } - }) - expect(picked.catalog).toBe(hosted.catalog) - const effort = structuredAgentSessionOptionSnapshot(picked).find( - (option) => option.id === 'effort' - ) - expect(effort?.kind).toMatchObject({ currentValue: 'medium' }) - }) - - it.each([ - ['claude', 'opus'], - ['codex', 'gpt-5.5'] - ])( - '%s shows a saved effort before its catalog loads and accepts the later list', - (agent, model) => { - const seed = getAgentSessionOptionCatalog(agent)! - const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState(agent, seed), - seed, - { models: [], modelsKnown: false, current: { model, effort: 'high' } } - ) - expect(state.catalog).toBe(seed) - expect(state.catalogSource).toBe('seed') - expect(structuredAgentSessionOptionSnapshot(state)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: model }) - }), - expect.objectContaining({ - id: 'effort', - kind: expect.objectContaining({ currentValue: 'high' }) - }) - ]) - ) - const loaded = applyStructuredAgentSessionModelCatalog( - state, - seed, - { ...HOST_CATALOG, models: [{ ...HOST_CATALOG.models[0]!, id: model }] }, - LAUNCH - ) - expect(loaded.catalogSource).toBe('host') - expect( - structuredAgentSessionOptionSnapshot(loaded).find((option) => option.id === 'effort')?.kind - ).toMatchObject({ currentValue: 'high' }) - expect(loaded.catalog?.models.map((row) => row.label)).toEqual(['GPT Hosted']) - } - ) - - it('keeps an unrecognized saved model quiet until the host lists it', () => { - const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - { models: [], modelsKnown: false, current: { model: 'gpt-hosted', effort: 'high' } } - ) - expect(structuredAgentSessionOptionSnapshot(state)).toEqual([ - expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) - ]) - const loaded = applyStructuredAgentSessionModelCatalog(state, SEED, HOST_CATALOG, LAUNCH) - expect(structuredAgentSessionOptionSnapshot(loaded)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: 'gpt-hosted' }) - }), - expect.objectContaining({ - id: 'effort', - kind: expect.objectContaining({ currentValue: 'high' }) - }) - ]) - ) - }) -}) From fe7ed3ab561e41148aef456d89740c4f8a9f69ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:59:24 -0700 Subject: [PATCH 28/31] Match startup picker fixture to saved message choices --- ...de-structured-startup-unanswered-control-request.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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' }) From 2979f938aec196a8af82990ead6c49df8f65160e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:44:33 -0700 Subject: [PATCH 29/31] Check startup effort after saved picker choices are acknowledged --- ...-structured-agent-session-options.test.tsx | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) 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..2054bcae76f2 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,36 @@ 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, calls } = mutateWith(async () => null) + calls.mockResolvedValueOnce({ key: 'effort', value: 'low' }) + calls.mockResolvedValueOnce({ key: 'model', value: 'sonnet' }) 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') + await act(async () => { + await 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('model', 'sonnet') }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') From 1b80d3f0a61aadb8deb18dabb282743845607181 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:48:19 -0700 Subject: [PATCH 30/31] Complete Stop target types and keep the effort regression concise --- .../structured-agent-session-mutation-plans.ts | 2 ++ .../native-chat/use-structured-agent-session-options.test.tsx | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) 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 a26191b2521b..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 @@ -5,6 +5,8 @@ // prompt answer's replay answers from the resolution its receipt keeps; the journal projects the // 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 { 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 2054bcae76f2..30e85b0c5656 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 @@ -524,9 +524,6 @@ describe('useStructuredAgentSessionOptions', () => { await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) await act(async () => { await result.current.setStructuredOption('effort', 'low') - }) - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - await act(async () => { await result.current.setStructuredOption('model', 'sonnet') }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') From 24dcf692e5dbe33a067faa259655b734cc3ae43d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:52:10 -0700 Subject: [PATCH 31/31] Use requested picker values in the effort regression acknowledgement --- .../native-chat/use-structured-agent-session-options.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 30e85b0c5656..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 @@ -514,9 +514,7 @@ describe('useStructuredAgentSessionOptions', () => { } } }) - const { mutate, calls } = mutateWith(async () => null) - calls.mockResolvedValueOnce({ key: 'effort', value: 'low' }) - calls.mockResolvedValueOnce({ key: 'model', value: 'sonnet' }) + const { mutate } = mutateWith(async (_description, _method, fields) => fields) const { result, unmount } = renderOptions( { ...REOPENED, agent: 'claude', providerStarting: true }, mutate