diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index cf20e98efeb..e2544461688 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -120,6 +120,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ '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-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-send-options.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts', diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 19dfa70f8cb..ca060ff0dc6 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 @@ -91,6 +91,7 @@ const HOST_SUPPORT = { queuedCommands: false, statusFeed: true, quietRepeatedStop: true, + sendOptions: false, conversationStop: false } 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 12283903ad3..fde0d2ac70a 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 @@ -8,6 +8,7 @@ import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' describe('structuredAgentSessionHostSupport', () => { @@ -19,6 +20,7 @@ describe('structuredAgentSessionHostSupport', () => { queuedCommands: false, quietRepeatedStop: false, statusFeed: false, + sendOptions: false, conversationStop: false } expect(structuredAgentSessionHostSupport([])).toEqual(none) @@ -40,6 +42,9 @@ describe('structuredAgentSessionHostSupport', () => { expect( structuredAgentSessionHostSupport([AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY]) ).toEqual({ ...none, statusFeed: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, sendOptions: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) ).toEqual({ ...none, conversationStop: 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 967cea4341c..abe1a19edbb 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -7,6 +7,7 @@ import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' /** Structured-session features the connected host advertised; null until the status probe answers. */ export type StructuredAgentSessionHostSupport = { @@ -21,6 +22,8 @@ export type StructuredAgentSessionHostSupport = { statusFeed: boolean /** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */ quietRepeatedStop: boolean + /** A send may carry the model and effort its turn runs on. */ + sendOptions: boolean /** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */ conversationStop: boolean } @@ -35,6 +38,7 @@ export function structuredAgentSessionHostSupport( 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), + sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index e2e1ebccbe0..f4e2bcd2c33 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -20,6 +20,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { attachments: readonly StructuredAgentSessionAttachment[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' + /** Sent only when the host advertises `agent-session.send-options.v1`. */ + options?: Readonly> deadline?: number /** The id the host records the message under. */ clientMessageId: string @@ -39,7 +41,8 @@ export async function sendMobileStructuredAgentSessionMessage(input: { expectedRuntimeFence: input.expectedRuntimeFence, fields: { body: structuredAgentSessionSendBody(input.text, input.attachments), - ...(input.delivery ? { delivery: input.delivery } : {}) + ...(input.delivery ? { delivery: input.delivery } : {}), + ...(input.options ? { options: input.options } : {}) }, clientOperationId: input.clientMessageId, timeoutMs 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 0779304fb02..9c71931ad89 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 @@ -48,7 +48,8 @@ async function mount(initial: Props) { sessionId: 'session-1', enabled: true, fence: props.fence, - mutate + mutate, + takesSendOptions: true }) frames.push(modelChoices(options.optionSnapshot)) return null diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 4bb01e3c163..1fbfb9cf25b 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -59,7 +59,8 @@ async function mountOptions(props: ProbeProps) { enabled: true, fence: probe.fence, mutate, - optionStamp: probe.optionStamp + optionStamp: probe.optionStamp, + takesSendOptions: true }) return null } diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 603c1486ba6..9c5e998aa9a 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -157,7 +157,8 @@ function Probe(props: ProbeProps): null { sessionId: props.sessionId, enabled: true, fence: props.fence, - mutate: props.mutate + mutate: props.mutate, + takesSendOptions: true }) ) return null diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 63a434f90b1..7a109f49f70 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -7,7 +7,6 @@ import type { AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' import type { - SessionOptionDescriptor, SessionOptionsSurface, SessionOptionValue } from '../../../src/shared/native-chat-session-options' @@ -18,9 +17,12 @@ import { commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, - structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' +import { + structuredAgentSessionOptionView, + structuredAgentSessionShownSnapshot +} from '../../../src/shared/structured-agent-session-option-view' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode' import type { RpcClient } from '../transport/rpc-client' import { @@ -35,18 +37,6 @@ import { } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' -type StructuredOptionsController = { - optionPickerRequest: { id: string; sequence: number } | null - conversationCommands: readonly AgentSessionConversationCommand[] - /** The chat takes images, as its options report says; else only Claude and Codex, as before. */ - acceptsImages: boolean - optionSnapshot: SessionOptionDescriptor[] - optionSurface: SessionOptionsSurface - pendingOptionId: string | null - setStructuredOption: (id: string, value: SessionOptionValue) => Promise - invokeStructuredOption: (id: string) => Promise -} - export function useMobileStructuredAgentOptions(args: { agent: string | null client: RpcClient | null @@ -56,8 +46,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 -}): StructuredOptionsController { - const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args + takesSendOptions: boolean +}) { + const { agent, client, enabled, fence, mutate, optionStamp, sessionId, takesSendOptions } = 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), @@ -173,8 +164,8 @@ export function useMobileStructuredAgentOptions(args: { ]) const optionSnapshot = useMemo( - () => structuredAgentSessionOptionSnapshot(optionState), - [optionState] + () => structuredAgentSessionShownSnapshot(optionState, takesSendOptions), + [optionState, takesSendOptions] ) const setStructuredOption = useCallback( @@ -187,7 +178,11 @@ export function useMobileStructuredAgentOptions(args: { !sessionId || !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(currentState, id, value) + !canSetStructuredAgentSessionOption( + structuredAgentSessionOptionView(currentState, undefined, {}, takesSendOptions), + id, + value + ) ) { return false } @@ -275,7 +270,7 @@ export function useMobileStructuredAgentOptions(args: { } } }, - [agent, client, mutate, optionCatalog, sessionId, updateOptionState] + [agent, client, mutate, optionCatalog, sessionId, takesSendOptions, updateOptionState] ) const invokeStructuredOption = useCallback( @@ -292,9 +287,11 @@ export function useMobileStructuredAgentOptions(args: { const setOption = useCallback( async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) - return { snapshot: structuredAgentSessionOptionSnapshot(optionStateRef.current) } + return { + snapshot: structuredAgentSessionShownSnapshot(optionStateRef.current, takesSendOptions) + } }, - [setStructuredOption] + [setStructuredOption, takesSendOptions] ) const optionSurface = useMemo( @@ -311,6 +308,7 @@ export function useMobileStructuredAgentOptions(args: { optionPickerRequest, conversationCommands: conversationSupport?.sessionId === sessionId ? conversationSupport.commands : [], + /** The chat takes images, as its options report says; else only Claude and Codex, as before. */ acceptsImages: (imageSupport?.sessionId === sessionId ? imageSupport.acceptsImages : undefined) ?? (agent !== null && isAgentSessionHandleProvider(agent)), 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 e45c4c11aa0..cd8ffcc6833 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 @@ -125,6 +125,7 @@ function Harness({ queuedCommands: false, statusFeed: false, quietRepeatedStop: false, + sendOptions: false, conversationStop: 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 68d323c8197..596fabbef86 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 @@ -19,6 +19,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { queuedCommands: false, statusFeed: false, quietRepeatedStop: false, + sendOptions: false, conversationStop: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index e079adea77b..925de3288ce 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -9,6 +9,11 @@ import { fieldsOf, ok } from './use-mobile-structured-agent-session-queued.test- import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { + structuredAgentSessionHostSupport, + type StructuredAgentSessionHostSupport +} from './mobile-structured-agent-session-host-support' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' import { @@ -66,6 +71,7 @@ describe('mobile structured send actions', () => { let listener: ((value: unknown) => void) | null = null let storedOperations: Map let agent = 'codex' + let hostSupport: StructuredAgentSessionHostSupport | null = null const onSendError = vi.fn() const sendRequest = vi.fn() const subscribe = vi.fn((_method, _params, onData) => { @@ -94,7 +100,7 @@ describe('mobile structured send actions', () => { enabled: true, connected: true, agent, - hostSupport: null, + hostSupport, onSendError }) return createElement('span', null, banner.message) @@ -146,6 +152,95 @@ describe('mobile structured send actions', () => { banner = null listener = null agent = 'codex' + hostSupport = null + }) + + it.each([ + ['carries the shown model and effort to a host that takes them', true], + ['sends the old shape to a host that does not', false] + ])('%s', async (_name, takesOptions) => { + agent = 'claude' + hostSupport = structuredAgentSessionHostSupport( + takesOptions ? [AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY] : [] + ) + const high = [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('accepted') + : method === 'agentSession.options' + ? ok({ + models: [{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts: high }], + current: { model: 'opus[1m]', effort: 'high' } + }) + : ok({}) + ) + await mountSession() + await act(async () => { + await vi.waitFor(() => + expect(sendRequest.mock.calls.some(([method]) => method === 'agentSession.options')).toBe( + true + ) + ) + }) + await act(async () => { + expect(await hook!.sendWithOutcome('hello')).toBe('accepted') + expect(await hook!.sendWithOutcome('again')).toBe('accepted') + }) + for (const [, params] of calls()) { + if (takesOptions) { + expect(fieldsOf(params).options).toEqual({ model: 'opus[1m]', effort: 'high' }) + } else { + expect(fieldsOf(params)).not.toHaveProperty('options') + } + } + }) + + it('takes an effort the replacement it shows offers, and sends on that replacement', async () => { + agent = 'claude' + hostSupport = structuredAgentSessionHostSupport([AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY]) + const efforts = [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('accepted') + : method === 'agentSession.modelCatalog' + ? ok({ + origin: 'probe', + fetchedAt: 1, + models: [{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts }] + }) + : method === 'agentSession.options' + ? ok({ + // A stopped chat lists the host's rows and its own model. + models: [ + { id: 'opus[1m]', label: 'Opus', isDefault: true, efforts }, + { id: 'claude-gone', label: 'claude-gone', isDefault: false, efforts: [] } + ], + current: { model: 'claude-gone', effort: 'high' } + }) + : ok({}) + ) + await mountSession() + await vi.waitFor(async () => { + await act(async () => {}) + expect( + hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model')?.kind + ).toMatchObject({ currentValue: 'opus[1m]' }) + }) + await act(async () => { + await hook!.setStructuredOption('effort', 'low') + }) + const picks = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.setOption') + expect(picks.map(([, params]) => fieldsOf(params).value)).toEqual(['low']) + await act(async () => { + expect(await hook!.sendWithOutcome('hello')).toBe('accepted') + }) + expect(fieldsOf(calls()[0]![1]).options).toMatchObject({ model: 'opus[1m]' }) }) it("sends a command the chat's own agent runs as a message, though Codex's host claims it", async () => { diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index f4d2e7073de..3dd7b707e93 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -106,7 +106,8 @@ export function useMobileStructuredAgentSession(args: { enabled, fence: state.fence, mutate, - optionStamp + optionStamp, + takesSendOptions: hostSupport?.sendOptions === true }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options @@ -193,7 +194,8 @@ export function useMobileStructuredAgentSession(args: { hold: sends.hold, commandPending: commandPendingRef, controller: sendController, - onSendError + onSendError, + carriesOptions: hostSupport?.sendOptions === true }) const turnIndicator = useMemo( () => ({ 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 a7b3c1c32b8..81a0d0b0fdf 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -20,6 +20,7 @@ import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-age import type { MobileStructuredSends } from './use-mobile-structured-sends' import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' +import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view' import { pendingStructuredApproval, pendingStructuredQuestion @@ -57,6 +58,8 @@ export function useMobileStructuredSendWithOutcome(args: { 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' > onSendError: MobileNativeChatSendErrorReporter + /** Each send carries the model and effort the picker shows; an older host takes neither. */ + carriesOptions?: boolean }): ( text: string, images?: string[], @@ -74,6 +77,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + carriesOptions, sessionId, stateRef, stopping @@ -138,6 +142,11 @@ export function useMobileStructuredSendWithOutcome(args: { queuedAt: Date.now(), ...(stopping ? { sentWhileStopping: true as const } : {}) } + // A pick on its way holds the send, so the picker shows what the chat holds. + const options = + carriesOptions && agent + ? structuredAgentSessionSendSelection(agent, controller.snapshot) + : undefined const outcome = await sendMobileStructuredAgentSessionMessage({ client, sessionId, @@ -147,6 +156,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), + ...(options ? { options } : {}), deadline, clientMessageId: send.clientMessageId, onError: onSendError @@ -169,6 +179,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + carriesOptions, sessionId, stateRef, stopping 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 61debb60c01..f4f59f733a3 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -267,8 +267,9 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { queuedMessages: false, queuedCommands: false, quietRepeatedStop: false, - conversationStop: false, - statusFeed + statusFeed, + sendOptions: false, + conversationStop: false }, agent: 'codex', onSendError 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 e87fb450154..2936fe96544 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 @@ -111,6 +111,7 @@ export class JournalQueuedMessages { hostInstance: string requireAttachments?: true holdReason?: QueuedMessageHoldReason + options?: Readonly> }, receipt?: JournalOperationReceipt ): Promise { 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 a6e3aa03d18..ea08945a4d1 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 @@ -90,6 +90,7 @@ export function journalSubmissionRowBuilder( handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + options?: Readonly> }, /** Present when the append hands off a queued draft: the row names that draft, stamped here * from the consume itself so no hand-off path can leave the link off. */ @@ -283,6 +284,7 @@ export function buildJournalSubmissionRow(input: { queuedMessageId?: string origin?: 'client' | 'host' source?: Pick + options?: Readonly> }): JournalSubmissionRow { return { kind: 'submission', @@ -295,6 +297,7 @@ export function buildJournalSubmissionRow(input: { ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), ...(input.origin !== undefined ? { origin: input.origin } : {}), // The kind only; who the senders are is on the message body. - ...(input.source !== undefined ? { source: { kind: input.source.kind } } : {}) + ...(input.source !== undefined ? { source: { kind: input.source.kind } } : {}), + ...(input.options ? { options: input.options } : {}) } } 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 f018f6307f9..477e8549ae6 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 @@ -165,6 +165,9 @@ export type JournalSubmissionRow = JournalRowBase & { /** Who it is from: its `AgentSessionMessageSource`'s kind only (`AgentJournalSubmission`). * Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ source?: { kind: string } + /** The model and effort the person's composer sent it with, applied when it is handed over. + * Older readers keep the key and ignore it. */ + options?: Readonly> } export type JournalSubmissionOrigin = 'client' | 'host' 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 f9f7502ea8b..3259d488b22 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 @@ -106,6 +106,8 @@ export type JournalSubmissionInput = { origin?: 'client' | 'host' /** Who it is from (`JournalSubmissionRow.source`); the row keeps the kind only. */ source?: Pick + /** The model and effort it runs on (`JournalSubmissionRow.options`). */ + options?: Readonly> } /** A submission append that converts a queued draft, in one transaction. */ 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 7b03b4f457f..2a71be5515c 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 @@ -11,6 +11,7 @@ import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import { journalDispatchRowApplies } from './journal-dispatch-settlement' +import { isAgentSessionOptions } from '../../../shared/agent-session-record' export function applyJournalSubmission( state: JournalReducerState, @@ -34,7 +35,9 @@ export function applyJournalSubmission( ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither // reads as a row without one. - ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) + ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}), + // A stored value this build cannot read only loses the model it carried, never the row. + ...(isAgentSessionOptions(row.options) ? { options: row.options } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. 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 fd5a774c7bf..ad3f6a11d55 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 @@ -146,7 +146,9 @@ export async function holdUnsentSends( ), hostInstance: input.hostInstance, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, - position: card.position + position: card.position, + // Kept with the message, so the card runs on what it was sent with. + ...(submission.options ? { options: submission.options } : {}) } : null, positions: moves 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 3f9d0122fda..492c38fb766 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'], + ['options_json', 'TEXT'] ] /** @@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, + 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 51233b66abb..16094cfd45e 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 { isAgentSessionOptions } from '../../../shared/agent-session-record' /** 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 + options_json: string | null } let body: AgentJournalMessageItem try { @@ -65,7 +67,21 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null + : null, + ...storedOptions(record.options_json) + } +} + +/** A bad value only loses the model the send carried, never the card. */ +function storedOptions(json: string | null): { options?: Record } { + if (json === null) { + return {} + } + try { + const options: unknown = JSON.parse(json) + return isAgentSessionOptions(options) ? { options } : {} + } catch { + return {} } } 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 9f255980448..827dd6111f6 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 @@ -61,10 +61,12 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null + /** The model and effort its send carried, applied to the turn it starts when it goes out. */ + options?: Readonly> } 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, options_json' export function insertQueuedMessage( db: Database.Database, @@ -79,6 +81,7 @@ export function insertQueuedMessage( /** Absent: after every other card. */ position?: number holdReason?: QueuedMessageHoldReason + options?: Readonly> } ): QueuedMessageRow { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. @@ -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,7 +103,8 @@ export function insertQueuedMessage( input.holdReason ?? null, null, input.queuedAt.epoch, - input.queuedAt.sequence + input.queuedAt.sequence, + input.options ? JSON.stringify(input.options) : null ) return { sessionId: input.sessionId, @@ -118,7 +122,8 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: null, - queuedAt: input.queuedAt + queuedAt: input.queuedAt, + ...(input.options ? { options: input.options } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 4e7cdf65eaf..54bb3e6b4b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -205,7 +205,8 @@ async function foundConversation( fields: { body: first.body } }), origin: 'client', - source: { kind: 'user' } + source: { kind: 'user' }, + ...openingMessageOptions(candidate.record.options) } ) if (!sent.ok) { @@ -284,3 +285,12 @@ export function structuredAgentSessionEntryDelegates( ) => createStructuredAgentSession(context(), caller.callerKey, params, options) } } + +/** The model and effort the chat was created with, which its opening message runs on as a send's + * carried ones do. */ +function openingMessageOptions(options: Readonly> | undefined): { + options?: Readonly> +} { + const { model, effort } = options ?? {} + return model ? { options: { model, ...(effort ? { effort } : {}) } } : {} +} 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 1858eaeddf8..f5c0c8ef1a3 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 @@ -53,6 +53,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessionId: string, startedFor: string ) => Promise + /** A message's model and effort, applied before its agent starts or it is handed over. */ + applyMessageOptions: ( + sessionId: string, + options: AgentJournalSubmission['options'] + ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number /** Settles queued messages as a completed close of the chat does; false when that failed. */ @@ -196,6 +201,9 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } + if (!session.child) { + await this.deps.applyMessageOptions(sessionId, oldest.options) + } const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) if (!ready.ok && ready.refusal.details?.reason === 'previousExitUnverifiable') { // Failed in the step that was refused: a message accepted, or an exit proven, after it must @@ -257,6 +265,7 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } + await this.deps.applyMessageOptions(sessionId, next.options) await handOverSubmission( { sessionId, 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 9791d4afda2..fafd6ddc6a9 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 @@ -24,6 +24,8 @@ 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 { applyStructuredAgentSessionMessageOptions } from './structured-agent-session-message-options' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -60,6 +62,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { ) => Promise /** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */ stopOutgrownAgent: (sessionId: string) => Promise + /** Read lazily: the host's mutation context is still assembling when this is built. */ + mutationContext: () => StructuredAgentSessionMutationContext clientDelivery: Pick< StructuredAgentSessionClientDelivery, 'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus' @@ -96,6 +100,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return input.ensureProviderChild(sessionId, startedFor) }, + applyMessageOptions: (sessionId, options) => + applyStructuredAgentSessionMessageOptions(input.mutationContext(), sessionId, options), conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), holdClosed: async (sessionId, which) => { 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 78002aa6f6c..87fd5567cb5 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 @@ -42,15 +42,14 @@ import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutati import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' -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 { readStructuredAgentSessionOptions, IN_PROCESS_OPTIONS_READER, - recordStructuredAgentSessionOptionIntent, type StructuredAgentSessionOptionsReader } from './structured-agent-session-options-read' +import { writeStructuredAgentSessionOption } from './structured-agent-session-message-options' export function sendStructuredAgentSessionTurn( context: StructuredAgentSessionMutationContext, @@ -60,6 +59,7 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + options?: Readonly> /** 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 @@ -210,20 +210,7 @@ export async function setStructuredAgentSessionOption( get conversationWrite() { return atRest() ? (true as const) : undefined }, - run: async (ctx) => { - if (atRest()) { - 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. - context.optionRevisions.advance(params.envelope.sessionId) - } - } + run: (ctx) => writeStructuredAgentSessionOption(context, ctx, params) }, openForProviderWrite(context, params.envelope) ) 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 62568e18a18..b5716d6cdfc 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 @@ -126,6 +126,7 @@ export class StructuredAgentSessionHost { agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), + mutationContext: () => this.mutationContext(), clientDelivery: this.clientDelivery }) this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { 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 00000000000..449b4567794 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -0,0 +1,76 @@ +// A message's model and effort, applied to the turn it starts the way a picker change is applied. + +import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' +import { performSetOption } from './structured-agent-session-turns-options' +import type { TurnOutcome } from './structured-agent-session-turns' + +/** With no child running, intent the next start replays; else the running child's own write. Either + * is recorded on the chat at `fence` and published. */ +export async function writeStructuredAgentSessionOption( + context: StructuredAgentSessionMutationContext, + target: { sessionId: string; fence: number }, + input: { key: string; value: string } +): Promise> { + const { sessionId, fence } = target + const session = context.sessions.get(sessionId) + const ctx = { + sessionId, + fence, + adapter: context.deps.adapter, + persistOptions: (options: Readonly>) => + context.deps.store + .replaceSessionOptions({ sessionId, fence, options, now: context.now() }) + .then(() => undefined), + publish: () => session && context.publish(sessionId, session.journal) + } + if (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !session?.child) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, input) + } + // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. + const wait = context.acquireAborts.begin(sessionId) + try { + return await performSetOption(ctx, input, wait.signal) + } finally { + wait.end() + // Whatever the write's outcome, the child may hold what it asked for now. + context.optionRevisions.advance(sessionId) + } +} + +/** A message's model and effort, applied as it is handed over or its agent starts for it: model + * first, so the effort lands under it, and a value the chat already holds writes nothing. A failed + * write is logged and never stops the message, which runs on what the chat holds. */ +export async function applyStructuredAgentSessionMessageOptions( + context: StructuredAgentSessionMutationContext, + sessionId: string, + options: Readonly> | undefined +): Promise { + const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence + if (!options || fence === undefined) { + return + } + const entries = Object.entries(options).sort( + ([a], [b]) => Number(b === 'model') - Number(a === 'model') + ) + for (const [key, value] of entries) { + if (context.deps.store.getRecord(sessionId)?.options?.[key] === value) { + continue + } + const written = await writeStructuredAgentSessionOption( + context, + { sessionId, fence }, + { key, value } + ).catch((error: unknown) => ({ ok: false as const, error })) + if (!written.ok) { + context.deps.logger.warn("applying a message's option failed", { + scope: 'message-options', + sessionId, + key, + error: 'error' in written ? written.error : written.refusal + }) + } + } +} 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 28fceb65b39..ed578ec8025 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 @@ -26,8 +26,9 @@ import type { AgentSessionConversationCommandResult } from '../../../shared/agen import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { StructuredAgentSessionBackgroundStopInput } from './structured-agent-session-background-stop' import { - agentSessionMessagePayload, - agentSessionSendBodyFingerprint + agentSessionSendBodyFingerprint, + agentSessionSendFields, + type StructuredAgentSessionSendMutation } from '../../../shared/structured-agent-session-send-mutation' import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, @@ -134,16 +135,15 @@ function sendSource(params: { return params.body.from ? AGENT_MESSAGE_SOURCE : undefined } -export function sendPlan(params: { - envelope: AgentSessionMutationEnvelope - body: AgentJournalMessageItem - retryUnknown?: true - delivery?: 'queue-if-active' - userSend?: true - /** A person's message the host sends for them; `userSend` is always one. */ - personsMessage?: true - beforeRun?: () => void -}): MutationPlan { +export function sendPlan( + params: StructuredAgentSessionSendMutation & { + retryUnknown?: true + userSend?: true + /** A person's message the host sends for them; `userSend` is always one. */ + personsMessage?: true + beforeRun?: () => void + } +): MutationPlan { // The operation id IS the client message id: one send, one durable row, one // key the client reconciles its optimistic bubble against. const clientMessageId = params.envelope.clientOperationId @@ -152,12 +152,9 @@ export function sendPlan(params: { operationIdScope: 'global', conversationWrite: true, commandReceipt: submissionOrDraftReceipt(clientMessageId), - // `delivery` joins the OPERATION fingerprint only; the submission row keeps + // Control fields join the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. - fields: { - body: agentSessionMessagePayload(params.body), - ...(params.delivery ? { delivery: params.delivery } : {}) - }, + fields: agentSessionSendFields(params), recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. @@ -169,6 +166,7 @@ export function sendPlan(params: { origin: params.userSend ? 'client' : 'host', ...(source ? { source } : {}), clientMessageId, + ...(params.options ? { options: params.options } : {}), // Body-only, so the reducer's echo aliasing never sees control fields or the sender. payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index ef7e36953bd..e5b6838f04a 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 @@ -35,7 +35,12 @@ 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 + /** The model and effort the send carries. */ + carried?: Readonly> +} export function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -95,7 +100,11 @@ export async function createQueuedMessageTestRig( const body = { ...hostTestMessage(text), ...(options?.from ? { from: options.from } : {}) } const clientOperationId = hostTestOperationId() // Fingerprinted as the host digests a send: the message without its sender. - const fields = { body: agentSessionMessagePayload(body), ...(delivery ? { delivery } : {}) } + const fields = { + body: agentSessionMessagePayload(body), + ...(delivery ? { delivery } : {}), + ...(options?.carried ? { options: options.carried } : {}) + } const result = host.send(QUEUED_RIG_CALLER, { envelope: envelope(fields, 'agentSession.send', clientOperationId), ...fields, @@ -284,6 +293,7 @@ export async function createQueuedMessageTestRig( cancelTurn, closeSession, compact, + setOption: provider.adapter.setOption, starts: provider.starts, holdNextStart: provider.holdNextStart, failNextStart: provider.failNextStart, 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 ac85a5ed8bf..d287b643ba5 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 @@ -181,6 +181,7 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + options?: Readonly> /** 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. */ @@ -233,6 +234,8 @@ export async function maybeQueueStructuredAgentSessionSend( // 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(), + // Applied when the card goes out, to the turn it starts. + ...(params.options ? { options: params.options } : {}), ...(params.userSend ? { requireAttachments: true } : {}) }, ctx.operationReceipt @@ -357,6 +360,7 @@ export class StructuredAgentSessionQueuedMessageDrain { origin: 'host', payloadFingerprint: next.fingerprint, body: next.body, + ...(next.options ? { options: next.options } : {}), 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 a3fee6e2a33..32675a1781b 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.options ? { options: row.options } : {}), fence: ctx.fence, handoverRecorded: true }, 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 d472e159fcb..7467a2afe84 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 @@ -14,6 +14,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + options?: Readonly> userSend?: true personsMessage?: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts new file mode 100644 index 00000000000..357d80224d3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts @@ -0,0 +1,309 @@ +// A send's carried model and effort against the real host, store and journal: kept with the +// message and applied as it is handed over (recorded before a start, written to a running child), +// skipped when the chat already holds it, never stopping the message, part of the operation's +// fingerprint, and kept on a queued card until the card goes out. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + CREATE_TEST_CALLER, + createTestParams +} from './structured-agent-session-create-test-fixture' +import { + acceptedDispatch, + createRestTestRig, + foundRestTestChat, + IDLE_MS, + REST_TEST_CALLER, + restTestSend +} from './structured-agent-session-rest-test-rig' + +const PICKED = { model: 'gpt-picked', effort: 'high' } + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function recordedOptions(): Readonly> | undefined { + return rig.store.getRecord(SESSION)?.options +} + +async function handedOver(id: string): Promise { + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) +} + +/** A running child with nothing in flight. */ +async function idleRunningChild(): Promise { + const working = await rig.workingSend() + await rig.settleAccepted(working, 'idle') +} + +describe('a send carrying its model and effort to a chat at rest', () => { + it('records them before the start its message needs, so the agent starts on them', async () => { + const rest = await createRestTestRig() + try { + await foundRestTestChat(rest) + rest.clock.now += IDLE_MS + 1 + await vi.waitFor(() => + expect(rest.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + const send = restTestSend('carry on', rest.store.getRecord(SESSION)?.lease.runtimeFence) + const fields = { body: send.body, options: PICKED } + const sent = await rest.host.send(REST_TEST_CALLER, { + ...send, + envelope: { + ...send.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + }, + options: PICKED + }) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rest.adapter.acquire).toHaveBeenCalledTimes(2)) + expect(rest.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ options: expect.objectContaining(PICKED) }) + ) + } finally { + await rest.dispose() + } + }) +}) + +describe('an opening message created with a model and effort', () => { + const OPENED = { model: 'opus', effort: 'high' } + + /** Creates the chat with its opening message; its agent stays starting until `proveStart`. */ + async function createWhileStarting( + rest: Awaited>, + options: Record = OPENED + ) { + const start = rest.adapter.acquire.getMockImplementation()! + rest.adapter.acquire.mockImplementationOnce(async (input) => ({ + ...(await start(input)), + providerChildPhase: 'starting' + })) + const first = { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening') } + const params = createTestParams(first, { options }) + expect( + await rest.host.create(CREATE_TEST_CALLER, params, { firstMessage: first }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(rest.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + const proveStart = async () => + rest.host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: rest.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: (await rest.adapter.acquire.mock.results[0]!.value) + .acquisitionGeneration, + reportedOptions: options, + restoreSkippedOptions: [], + optionRevision: rest.host + .collaboratorsForTests() + .runtimeState.optionRevisions.current(SESSION) + }) + return { first, proveStart } + } + + it('runs on them though a pick made before its handover changed the chat', async () => { + const rest = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + try { + // Model only: this rig's agent answers a pick with nothing, so each write replaces the record. + const { proveStart } = await createWhileStarting(rest, { model: 'opus' }) + const fields = { key: 'model', value: 'sonnet' } + const send = restTestSend('unused', rest.store.getRecord(SESSION)?.lease.runtimeFence) + const envelope = { + ...send.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + } + expect(await rest.host.setOption(REST_TEST_CALLER, { envelope, ...fields })).toMatchObject({ + ok: true + }) + expect(rest.store.getRecord(SESSION)?.options?.model).toBe('sonnet') + const ranOn: (string | undefined)[] = [] + rest.adapter.dispatch.mockImplementationOnce(async () => { + ranOn.push(rest.store.getRecord(SESSION)?.options?.model) + return acceptedDispatch() + }) + await proveStart() + await vi.waitFor(() => expect(rest.adapter.dispatch).toHaveBeenCalledOnce()) + expect(ranOn).toEqual(['opus']) + } finally { + await rest.dispose() + } + }) + + it('keeps them on the card Orca keeps when it stops before the handover', async () => { + const rest = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + try { + const { first } = await createWhileStarting(rest) + const host = await rest.restart() + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + ok: true + }) + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + expect(journal?.queuedMessages.list()).toMatchObject([ + { messageId: first.clientMessageId, options: OPENED } + ]) + } finally { + await rest.dispose() + } + }) +}) + +describe('a send carrying its model and effort', () => { + it('writes them to a running child as it is handed over, model first, skipping what the chat holds', async () => { + await idleRunningChild() + const first = rig.send('switch', undefined, { carried: PICKED }) + await first.result + await handedOver(first.id) + expect(vi.mocked(rig.setOption).mock.calls.map(([input]) => input.key)).toEqual([ + 'model', + 'effort' + ]) + expect(recordedOptions()).toMatchObject({ effort: 'high' }) + await rig.settleAccepted(first.id, 'switch') + + vi.mocked(rig.setOption).mockClear() + const again = rig.send('same again', undefined, { carried: { effort: 'high' } }) + await again.result + await handedOver(again.id) + expect(rig.setOption).not.toHaveBeenCalled() + }) + + it("runs an accepted message on its own selection, though a later pick changed the chat's", async () => { + const starting = await createQueuedMessageTestRig({ starting: true }) + try { + const sent = starting.send('mine', undefined, { carried: { model: 'gpt-a' } }) + expect(await sent.result).toMatchObject({ ok: true }) + await eventually(() => expect(starting.starts).toHaveBeenCalled()) + // Picked while the message waits for its agent to start. + const fields = { key: 'model', value: 'gpt-b' } + await starting.host.setOption(CALLER, { + envelope: starting.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + expect(starting.store.getRecord(SESSION)?.options?.model).toBe('gpt-b') + await starting.proveStart() + await eventually(async () => + expect((await starting.submission(sent.id))?.handedOverAt).toBeDefined() + ) + const setOption = vi.mocked(starting.setOption) + expect(setOption.mock.calls.at(-1)?.[0]).toMatchObject({ key: 'model', value: 'gpt-a' }) + expect(setOption.mock.invocationCallOrder.at(-1)).toBeLessThan( + starting.dispatch.mock.invocationCallOrder.at(-1) ?? 0 + ) + } finally { + await starting.dispose() + } + }) + + it('still sends when the write fails, on what the chat holds', async () => { + await idleRunningChild() + vi.mocked(rig.setOption).mockRejectedValueOnce(new Error('child refused')) + const { id, result } = rig.send('anyway', undefined, { carried: { model: 'gpt-picked' } }) + expect(await result).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) + expect(recordedOptions()?.model).toBeUndefined() + }) + + it('fingerprints them with the operation, so the same id with other options conflicts', async () => { + const body = hostTestMessage('once') + const clientOperationId = hostTestOperationId() + const params = (options: Record) => { + const fields = { body: agentSessionMessagePayload(body), options } + return { + envelope: rig.envelope(fields, 'agentSession.send', clientOperationId), + ...fields, + body + } + } + expect(await rig.host.send(CALLER, params(PICKED))).toMatchObject({ ok: true }) + expect(await rig.host.send(CALLER, params({ model: 'other' }))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + }) +}) + +describe('a queued send carrying its model and effort', () => { + async function queued(carried: Readonly>): Promise<{ + working: string + draftId: string + }> { + const working = await rig.workingSend() + const answer = await rig.send('later', 'queue-if-active', { carried }).result + if (!answer.ok || !('queued' in answer.value)) { + throw new Error('expected a queued receipt') + } + return { working, draftId: answer.value.queued.messageId } + } + + it('keeps them on the card and applies them when the queue sends it', async () => { + const { working, draftId } = await queued({ model: 'gpt-picked' }) + expect(rig.setOption).not.toHaveBeenCalled() + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await eventually(() => + expect(rig.setOption).toHaveBeenCalledWith( + expect.objectContaining({ key: 'model', value: 'gpt-picked' }) + ) + ) + }) + + it('applies them when the card is sent by hand', async () => { + const { working, draftId } = await queued({ model: 'gpt-picked' }) + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'a') + await handedOver(await rig.handoffId(draftId)) + expect(rig.setOption).toHaveBeenCalledWith( + expect.objectContaining({ key: 'model', value: 'gpt-picked' }) + ) + }) +}) + +describe('a send kept as a card because Orca stopped before handing it over', () => { + it('keeps its model and effort on the card', async () => { + const kept = await createQueuedMessageTestRig({ restartable: true }) + try { + await kept.host.close(SESSION, 'evict') + const startsBefore = kept.starts.mock.calls.length + kept.holdNextStart() + const sent = kept.send('picked', undefined, { carried: PICKED }) + expect(await sent.result).toMatchObject({ ok: true }) + await eventually(() => expect(kept.starts.mock.calls.length).toBeGreaterThan(startsBefore)) + await kept.crashRestartHostProcess() + expect(await kept.drafts()).toEqual([{ messageId: sent.id, state: 'waiting' }]) + const journal = kept.host.collaboratorsForTests().sessions.get(SESSION)?.journal + expect(journal?.queuedMessages.list()).toMatchObject([ + { messageId: sent.id, options: PICKED } + ]) + } finally { + await kept.dispose() + } + }) +}) 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 index ffee03165f3..dcfb9c1f666 100644 --- 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 @@ -3,7 +3,10 @@ import { isAgentSessionOptionRejectedError } from './structured-agent-session-op import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' export async function performSetOption( - ctx: AgentSessionTurnContext, + ctx: Pick< + AgentSessionTurnContext, + 'sessionId' | 'adapter' | 'fence' | 'persistOptions' | 'publish' + >, input: { key: string; value: string }, signal?: AbortSignal ): Promise> { 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 9e2863d8f90..2726c20f073 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 @@ -146,6 +146,8 @@ export async function performSend( origin?: 'client' | 'host' /** Who it is from; the submission keeps the kind only. */ source?: AgentSessionMessageSource + /** The model and effort it runs on, applied at its handover. */ + options?: Readonly> } ): Promise> { const existing = ctx.journal diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts index 156a4d02dd5..9f4492083f6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts @@ -135,6 +135,20 @@ describe('structured agent session message sender', () => { }) }) + it('carries the options read at the send on its request, and nothing without them', async () => { + const calls = deferredCalls() + const a = send('a', { options: { model: 'opus', effort: 'high' } }) + await flush() + expect(mocks.call.mock.calls[0][2]).toMatchObject({ + options: { model: 'opus', effort: 'high' } + }) + calls[0].resolve(okSubmission(a.clientMessageId, 'accepted')) + await a.outcome + send('b') + await flush() + expect(mocks.call.mock.calls[1][2]).not.toHaveProperty('options') + }) + it.each([ ['recorded', (calls: Deferred[], id: string) => calls[0].resolve(okSubmission(id, 'accepted'))], ['refused', (calls: Deferred[]) => calls[0].resolve(refusedSend)], 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 24ee3073779..58483e6f2d3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -10,6 +10,7 @@ export type StructuredAgentSessionPendingSend = { previewUris: readonly string[] queuedAt: number delivery?: 'queue-if-active' + options?: Readonly> /** * `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 076ce047444..e57a49ea883 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.options ? { options: entry.options } : {}) }) 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 e54850bdecd..aad2f2edbc5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts @@ -20,6 +20,8 @@ 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' + /** The composer's model and effort for the turn it starts, read once at the send. */ + options?: Readonly> /** 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.options ? { options: input.options } : {}), ...(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-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index a702aa38fa6..7317d342621 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 @@ -12,6 +12,12 @@ import { type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { + structuredAgentSessionOptionView, + type StructuredAgentSessionPendingPick +} from '../../../../shared/structured-agent-session-option-view' +import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' import { @@ -21,7 +27,7 @@ import { /** The first frame: the host's saved list when the renderer already holds it, else the seed, * which shows the quiet placeholder until the host answers. */ -function firstFrameOptionState( +export function firstFrameOptionState( agent: AgentType, target: RuntimeClientTarget, launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean } @@ -97,7 +103,8 @@ export function useStructuredAgentSessionOptionState(args: { }, [newLaunch, seedsModel, target, worktree]) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) + // A pick on its way to the host, which every send carries too. + const pendingOptionRef = useRef(null) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -197,8 +204,23 @@ 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 takesSendOptions = useStructuredAgentSessionHostCapability( + target, + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY + ) + // What the picker shows and a send carries, for this session's host. + const optionView = useCallback( + ( + state: StructuredAgentSessionOptionState, + seed: Readonly> | undefined, + held: Readonly> + ) => structuredAgentSessionOptionView(state, seed, held, takesSendOptions), + [takesSendOptions] + ) return { + optionView, + takesSendOptions, conversationSupport, rereadOptions, optionState, 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 c2021a3008f..b6cfd316a15 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 @@ -17,7 +17,6 @@ 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 { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' @@ -69,6 +68,8 @@ export function useStructuredAgentSessionOptions(args: { const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const identity = `${agent}:${sessionId}` const { + optionView, + takesSendOptions, optionState, optionStateRef, activeOptionRecordRef, @@ -145,7 +146,7 @@ export function useStructuredAgentSessionOptions(args: { : current ) } else { - pendingOptionRef.current = id + pendingOptionRef.current = { id, value: encoded } updateOptionState((current) => ({ ...current, pendingId: id })) } let result: AgentSessionOptionResult | null = null @@ -164,7 +165,7 @@ export function useStructuredAgentSessionOptions(args: { ) // The launch seed names the model an effort-only pick was made under. rememberOptionPicks( - structuredAgentSessionOptionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), + optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), committed ) } @@ -205,6 +206,7 @@ export function useStructuredAgentSessionOptions(args: { optionCatalog, optionMutationGeneration, optionStateRef, + optionView, pendingOptionRef, rememberOptionPicks, sessionId, @@ -218,26 +220,22 @@ export function useStructuredAgentSessionOptions(args: { toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) } else if (outcome.kind === 'accepted') { rememberOptionPicks( - structuredAgentSessionOptionView( - optionStateRef.current, - launchSeedOptions, - NO_HELD_OPTIONS - ), + optionView(optionStateRef.current, launchSeedOptions, NO_HELD_OPTIONS), outcome.options ) } }, - [launchSeedOptions, optionStateRef, rememberOptionPicks] + [launchSeedOptions, optionStateRef, optionView, rememberOptionPicks] ) const optionSnapshot = useMemo(() => { const snapshot = structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView(optionState, launchSeedOptions, held) + optionView(optionState, launchSeedOptions, held) ) return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot) - }, [acceptsPicks, held, launchSeedOptions, optionState]) + }, [acceptsPicks, held, launchSeedOptions, optionState, optionView]) const setStructuredOption = useCallback( async (id: string, value: string | boolean): Promise => { - const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held) + const view = optionView(optionStateRef.current, launchSeedOptions, held) const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || @@ -263,6 +261,7 @@ export function useStructuredAgentSessionOptions(args: { launchSeedOptions, optionCatalog, optionStateRef, + optionView, pendingOptionRef, sendStructuredOption, sessionId, @@ -277,15 +276,11 @@ export function useStructuredAgentSessionOptions(args: { const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held return { snapshot: structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView( - optionStateRef.current, - launchSeedOptions, - currentHeld ?? NO_HELD_OPTIONS - ) + optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) ) } }, - [launchSeedOptions, optionStateRef, sessionId, setStructuredOption] + [launchSeedOptions, optionStateRef, optionView, sessionId, setStructuredOption] ) const optionSurface = useMemo( () => ({ @@ -312,6 +307,7 @@ export function useStructuredAgentSessionOptions(args: { optionSnapshot, optionSurface, setStructuredOption, + sendSource: { optionStateRef, pendingOptionRef, optionView, takesSendOptions }, unavailable: hostCatalog.unavailable } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx new file mode 100644 index 00000000000..6b72784b386 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + getStructuredAgentSessionLaunchSelection: () => null +})) + +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-claude-codex' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import { + structuredAgentSessionOptionView, + type StructuredAgentSessionPendingPick +} from '../../../../shared/structured-agent-session-option-view' +import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options' + +const levels = (...values: string[]) => values.map((value) => ({ value, label: value })) + +function sendOptionsFor( + takesSendOptions: boolean, + pending: StructuredAgentSessionPendingPick | null, + seedOptions: Record | undefined = { model: 'claude-gone', effort: 'max' } +) { + const optionStateRef = { + current: applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', CLAUDE_SESSION_OPTION_CATALOG), + CLAUDE_SESSION_OPTION_CATALOG, + { + origin: 'probe', + fetchedAt: 1, + models: [ + { id: 'opus[1m]', label: 'Opus', isDefault: false, efforts: levels('low', 'xhigh') }, + { id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: levels('low', 'high') } + ] + }, + { newLaunch: true } + ) + } + const { result } = renderHook(() => + useStructuredAgentSessionSendOptions( + 'session-1', + { kind: 'new', seedOptions, heldOptions: {} }, + { + optionStateRef, + pendingOptionRef: { current: pending }, + optionView: (state, seed, held) => + structuredAgentSessionOptionView(state, seed, held, takesSendOptions), + takesSendOptions + } + ) + ) + return result.current +} + +describe('what each composer send carries', () => { + it('carries the shown model and effort to a host that takes them, on every send', () => { + const sendOptions = sendOptionsFor(true, null) + expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' }) + expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' }) + }) + + it('carries the default the composer shows when nothing names a model', () => { + expect(sendOptionsFor(true, null, undefined)()).toEqual({ model: 'sonnet', effort: 'high' }) + }) + + it('carries a pick still on its way, resolved as the picker shows it', () => { + expect(sendOptionsFor(true, { id: 'model', value: 'opus[1m]' })()).toMatchObject({ + model: 'opus[1m]' + }) + // A gone model the pick names moves to the listed default, as the picker shows. + expect(sendOptionsFor(true, { id: 'model', value: 'claude-retired' })()).toMatchObject({ + model: 'sonnet' + }) + }) + + it('carries nothing to an older host', () => { + expect(sendOptionsFor(false, null)()).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts new file mode 100644 index 00000000000..56e8d5b53ee --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react' +import { structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' +import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' +import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' +import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' + +/** What each send carries to a host that takes it: the model and effort the composer shows now. */ +export function useStructuredAgentSessionSendOptions( + sessionId: string, + launch: StructuredAgentSessionLaunchView | undefined, + source: Pick< + ReturnType, + 'optionStateRef' | 'pendingOptionRef' | 'optionView' | 'takesSendOptions' + > +): () => Readonly> | undefined { + const { optionStateRef, optionView, pendingOptionRef, takesSendOptions } = source + return useCallback(() => { + if (!takesSendOptions) { + return undefined + } + // Read, not rendered: a pick the launch just took is not in this render's props yet. + const selection = getStructuredAgentSessionLaunchSelection(sessionId) + // A pick on its way resolves as the picker shows it, so a gone model it names is replaced too. + const pending = pendingOptionRef.current + const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, { + ...(selection?.held ?? launch?.heldOptions), + ...(pending ? { [pending.id]: pending.value } : {}) + }) + return structuredAgentSessionSendSelection( + view.record.agent, + structuredAgentSessionOptionSnapshot(view) + ) + }, [launch, optionStateRef, optionView, pendingOptionRef, sessionId, takesSendOptions]) +} 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 911b4bff408..86dd2f8df43 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 @@ -35,6 +35,8 @@ export function useStructuredAgentSessionSends(args: { isWorking?: boolean /** The chat reads Stopping: a send made now is drawn after the turn being stopped. */ stopping?: boolean + /** The composer's model and effort each send carries; undefined to a host that takes none. */ + sendOptions?: () => Readonly> | undefined }) { const { fence, @@ -42,6 +44,7 @@ export function useStructuredAgentSessionSends(args: { isWorking = false, queue, queuedMessageIds, + sendOptions, sessionId, stopping, submissions, @@ -91,12 +94,14 @@ export function useStructuredAgentSessionSends(args: { return false } const delivery = structuredAgentSessionQueueRequest(queue, attachments) + const options = sendOptions?.() const sent = sendStructuredAgentSessionMessage({ sessionId, target, text, attachments, ...(delivery ? { delivery } : {}), + ...(options ? { options } : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. @@ -112,7 +117,7 @@ export function useStructuredAgentSessionSends(args: { ? 'queued' : true }, - [isWorking, queue, queuedMessageIds, sessionId, stopping, target] + [isWorking, queue, queuedMessageIds, sendOptions, 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 8e665d1c3f8..25d8915056c 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 @@ -1,4 +1,5 @@ import { useMemo, useRef } from 'react' +import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' import { useStructuredAgentSessionCommandWrite } from './use-structured-agent-session-command-write' import { structuredAgentSessionNewSendsQueue } from './structured-agent-session-queue-request' @@ -103,6 +104,7 @@ export function useStructuredAgentSession(args: { optionSnapshot, optionSurface, setStructuredOption, + sendSource, unavailable, threadGoal: threadGoalSupport, contextUsage: contextUsageSupport, @@ -124,6 +126,7 @@ export function useStructuredAgentSession(args: { mutate, ...(launch ? { launch } : {}) }) + const sendOptions = useStructuredAgentSessionSendOptions(sessionId, launch, sendSource) // Only a capable host may see `delivery`; a card any host publishes shows, with its actions. const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) const queuedMessageIds = useMemo( @@ -158,7 +161,8 @@ export function useStructuredAgentSession(args: { queue, historyLoaded: transportEnabled && state.status === 'ready', isWorking: transportState.isWorking, - stopping: stopControl.stopping + stopping: stopControl.stopping, + sendOptions }) const threadGoal = useStructuredAgentSessionThreadGoal({ diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 45613b5c7aa..74d4a7b663f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -10,12 +10,16 @@ import { structuredAgentSessionCreateFingerprint } from '../../../shared/structu import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { HostCreateSupport } from './structured-agent-session-host-admission' -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - supports: vi.fn<() => Promise>(), - admit: vi.fn<() => Promise>(), - inventory: vi.fn() -})) +const mocks = vi.hoisted(() => { + const settings: Record = {} + return { + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + supports: vi.fn<() => Promise>(), + admit: vi.fn<() => Promise>(), + inventory: vi.fn(), + settings + } +}) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call, supportsStructuredAgentSessionCreateMessage: mocks.supports @@ -29,7 +33,7 @@ vi.mock('@/runtime/structured-session-tab-inventory', () => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ - settings: {}, + settings: mocks.settings, unifiedTabsByWorktree: {}, seedNativeChatLaunchDraft: vi.fn(), clearNativeChatLaunchDraft: vi.fn() @@ -46,6 +50,7 @@ import { getStructuredAgentSessionLaunchLifecycle } from './structured-agent-session-launch' import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' +import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests, @@ -193,6 +198,39 @@ describe('the first message owned by create', () => { expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) }) + it.each([ + ['new', {}], + ['resumed', { resumeFrom: { providerSessionId: 'provider-session-1' } }] + ])( + "carries the model the host's saved list moves a gone saved one to, %s, as a send does", + async (_kind, resume) => { + mocks.settings = { + nativeChatSessionOptions: { + claude: { model: 'claude-gone' } + } + } + const xhigh = [{ value: 'xhigh', label: 'xhigh' }] + recordHostModelCatalogSnapshot(target, 'claude', null, { + origin: 'probe', + fetchedAt: 1, + models: [{ id: 'opus[1m]', label: 'Opus', isDefault: true, efforts: xhigh }] + }) + mocks.call.mockImplementation(async () => created()) + try { + const launch = startStructuredAgentLaunch('workspace-1', 'claude', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + ...resume + }) + await launch.launchResult + expect(createParams().options?.model).toBe('opus[1m]') + } finally { + mocks.settings = {} + } + } + ) + it('returns a refused create once after the new draft, with no later send', async () => { const create = deferred() mocks.call.mockReturnValue(create.promise) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index e04f44b15e1..3535c9ceb0d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -6,6 +6,12 @@ import { prepareLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { + structuredAgentSessionOptionView, + structuredAgentSessionSendSelection +} from '../../../shared/structured-agent-session-option-view' +import { structuredAgentSessionOptionSnapshot } from '../../../shared/structured-agent-session-options' +import { firstFrameOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' import { applyStructuredLaunchHeldOptions, settleStructuredLaunchCreateOptions @@ -24,7 +30,7 @@ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchStat return } const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) - const options = Object.keys(state.selection.held).length ? { ...state.selection.held } : undefined + const options = structuredLaunchCreateOptions(state) const params = { ...intent.params, ...(firstMessage ? { firstMessage } : {}), @@ -44,6 +50,31 @@ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchStat notifyStructuredLaunchListeners() } +/** Picks held so far, and the model and effort the chat shows: a first message starts its turn + * on them, as a later send does. */ +function structuredLaunchCreateOptions( + state: StructuredLaunchState +): Readonly> | undefined { + const { intent, selection } = state + const firstFrame = firstFrameOptionState(intent.agent, intent.target, { + newLaunch: !intent.params.resumeFrom, + worktree: intent.params.worktree, + seedsModel: intent.seedOptions?.model !== undefined + }) + // Create has always carried options, so any host takes what the chat shows. + const view = structuredAgentSessionOptionView( + firstFrame, + intent.seedOptions, + selection.held, + true + ) + const options = { + ...selection.held, + ...structuredAgentSessionSendSelection(intent.agent, structuredAgentSessionOptionSnapshot(view)) + } + return Object.keys(options).length ? options : undefined +} + /** Later picks use the normal option path without holding publication behind startup. */ export function publishStructuredLaunchCreateOptions( state: StructuredLaunchState, diff --git a/src/shared/agent-session-create-capabilities.ts b/src/shared/agent-session-create-capabilities.ts index ecc5e094016..3e5bccea7c7 100644 --- a/src/shared/agent-session-create-capabilities.ts +++ b/src/shared/agent-session-create-capabilities.ts @@ -1,4 +1,5 @@ -// The capabilities that gate what an agent-session create (structured or terminal) may carry. +// The capabilities that gate what an agent-session create (structured or terminal) or an +// `agentSession.send` may carry. // Why: `agentSession.create` is a strict object, so an older host refuses a payload carrying the // reserved `tabId` rather than ignoring it. A client sends the field only to a host advertising this. @@ -9,6 +10,11 @@ export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = export const AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY = 'agentSession.create.message.v1' as const +// Why: `agentSession.send` is a strict object, so an older host refuses `options`. A host advertising +// this applies a send's model and effort to the turn it starts and records them on the chat. +export const AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY = + 'agent-session.send-options.v1' as const + // Why: terminal.createAgentSession and terminal.ensureAgentSession are strict objects too, so an // older host refuses `accountEnv`. A client sends it only to a host advertising this. export const AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY = 'agent-session.account-env.v1' as const @@ -16,5 +22,6 @@ export const AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY = 'agent-session.accou export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [ AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/agent-session-journal-submission-schema.ts b/src/shared/agent-session-journal-submission-schema.ts index ef65649badb..08631c00157 100644 --- a/src/shared/agent-session-journal-submission-schema.ts +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -26,7 +26,8 @@ export const AgentJournalSubmissionSchema = z.object({ queuedMessageId: z.string().min(1).optional(), // The kind only; an object's other keys (a sender) are stripped, never published. source: z.object({ kind: z.string() }).optional(), - keptAsQueuedMessageId: z.string().min(1).optional() + keptAsQueuedMessageId: z.string().min(1).optional(), + options: z.record(z.string(), z.string()).optional() }) export function isAdmissibleAgentJournalSubmission( diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 2ff04f88ad4..1f556e92252 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -517,6 +517,9 @@ export type AgentJournalSubmission = { * card, so no surface draws this send, before or after the card is sent, edited or deleted. * Recorded in the rejection's own transaction (`journal-unsent-send-hold.ts`). */ keptAsQueuedMessageId?: string + /** The model and effort the person's composer sent it with, applied when it is handed over. + * The host's alone: clients ignore it. */ + options?: Readonly> } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index d9dd55c7b44..21a6c682120 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -127,6 +127,8 @@ export const CreateSupportParams = z }) .strict() +const AgentSessionOptions = z.record(z.string(), z.string()).refine(isAgentSessionOptions) + export const SendParams = z .object({ envelope: MutationEnvelope, @@ -135,6 +137,9 @@ 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(), + /** The composer's model and effort for the turn this send starts; the host records them on the + * chat. Sent only when `agent-session.send-options.v1` is advertised. Operation fingerprint only. */ + options: AgentSessionOptions.optional(), body: SendBody }) .strict() @@ -155,7 +160,7 @@ export const CreateIntentParams = z }) .strict() .optional(), - options: z.record(z.string(), z.string()).refine(isAgentSessionOptions).optional() + options: AgentSessionOptions.optional() }) .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 7586fb22bb9..9947fdd2ed6 100644 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ b/src/shared/structured-agent-session-model-catalog-application.test.ts @@ -133,7 +133,11 @@ describe('structured option state from the host model catalog', () => { 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) + // The host list stays; the answer only says it is no longer a finished listing. + expect(applyStructuredAgentSessionModelCatalog(hosted, SEED, answer, LAUNCH)).toEqual({ + ...hosted, + verifiedModelIds: undefined + }) } }) @@ -147,7 +151,10 @@ describe('structured option state from the host model catalog', () => { } ) expect(live.catalogSource).toBe('live') - expect(applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, LAUNCH)).toBe(live) + expect(applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, LAUNCH)).toEqual({ + ...live, + verifiedModelIds: ['gpt-hosted'] + }) const snapshot = structuredAgentSessionOptionSnapshot(live) const model = snapshot.find((descriptor) => descriptor.id === 'model')! expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-live') diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts new file mode 100644 index 00000000000..2f86af05124 --- /dev/null +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest' +import { CLAUDE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' +import type { AgentSessionModelCatalogResult } from './agent-session-wire' +import { + applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from './structured-agent-session-options' +import { + nearestAgentEffort, + structuredAgentSessionListedPicks, + structuredAgentSessionOptionView, + structuredAgentSessionSendSelection, + structuredAgentSessionShownSnapshot +} from './structured-agent-session-option-view' + +const levels = (...values: string[]) => values.map((value) => ({ value, label: value })) +const OPUS = { id: 'opus[1m]', label: 'Opus', isDefault: false, efforts: levels('low', 'xhigh') } +const SONNET = { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: levels('low', 'high') } +// Claude's session-less listing marks no default row. +const LISTED: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models: [OPUS] } +const remembered = { model: 'claude-gone', effort: 'max' } + +const fresh = () => createStructuredAgentSessionOptionState('claude', CLAUDE_SESSION_OPTION_CATALOG) +const withHostList = (state = fresh(), catalog: AgentSessionModelCatalogResult = LISTED) => + applyStructuredAgentSessionModelCatalog(state, CLAUDE_SESSION_OPTION_CATALOG, catalog, { + newLaunch: true + }) +// A stopped chat's own answer lists the host's rows plus its own model, named by its id. +const stopped = (model: string, rows = [OPUS]) => + applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { + models: [...rows, { id: model, label: model, isDefault: false, efforts: [] }], + current: { model, effort: 'high' } + }) + +function shown(...args: Parameters) { + const snapshot = structuredAgentSessionOptionSnapshot(structuredAgentSessionOptionView(...args)) + const value = (id: string) => { + const kind = snapshot.find((descriptor) => descriptor.id === id)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined + } + return { model: value('model'), effort: value('effort') } +} + +describe('a Claude model the verified list no longer offers', () => { + it('shows a new chat on the first listed row, effort moved to the nearest level', () => { + const state = withHostList() + expect(shown(state, remembered, {}, true)).toEqual({ model: 'opus[1m]', effort: 'xhigh' }) + expect(structuredAgentSessionListedPicks(state, remembered, {})).toEqual({ + model: 'opus[1m]', + effort: 'xhigh' + }) + // Derived only: the record never takes it. + expect(state.record.model).toBeUndefined() + }) + + it('shows a stopped chat on it too, its own unlisted row aside', () => { + expect(shown(withHostList(stopped('claude-gone')), undefined, {}, true)).toEqual({ + model: 'opus[1m]', + effort: 'xhigh' + }) + }) + + it('keeps a model the listing names, and takes a listed row whose label is its id', () => { + const live = withHostList(stopped('sonnet', [OPUS, SONNET]), { + ...LISTED, + models: [OPUS, SONNET] + }) + expect(structuredAgentSessionListedPicks(live, undefined, {})).toBeNull() + const held = { model: 'opus[1m]' } + expect(structuredAgentSessionListedPicks(withHostList(), remembered, held)).toBeNull() + // A listing with no display names names each row by its id. + const plain = { id: 'opus', label: 'opus', isDefault: true, efforts: levels('low', 'high') } + const unnamed = withHostList(fresh(), { ...LISTED, models: [SONNET, plain] }) + expect(structuredAgentSessionListedPicks(unnamed, remembered, {})).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + + it('moves a model only the session itself lists, as the listing does not name it', () => { + const live = withHostList(stopped('sonnet', [OPUS, SONNET])) + expect(structuredAgentSessionListedPicks(live, undefined, {})).toEqual({ + model: 'opus[1m]', + effort: 'xhigh' + }) + }) + + it("shows the chat's own model to a host that cannot take a send's model", () => { + expect(shown(withHostList(stopped('claude-gone')), undefined, {}, false).model).toBe( + 'claude-gone' + ) + }) + + it.each<[string, AgentSessionModelCatalogResult]>([ + ['still listing', { ...LISTED, listingInProgress: true }], + ['behind a failed sign-in', { ...LISTED, unavailable: { reason: 'notSignedIn' } }] + ])('keeps the model while the list is %s', (_name, catalog) => { + const state = withHostList(fresh(), catalog) + expect(structuredAgentSessionListedPicks(state, remembered, {})).toBeNull() + expect( + structuredAgentSessionListedPicks( + withHostList(stopped('claude-gone'), catalog), + undefined, + {} + ) + ).toBeNull() + }) + + it('takes no row the finished listing does not name, as from a built-in fallback list', () => { + // A live answer from before startup finished lists the built-in rows, not the account's. + const fallback = applyStructuredAgentSessionOptions( + withHostList(), + CLAUDE_SESSION_OPTION_CATALOG, + { + models: CLAUDE_SESSION_OPTION_CATALOG.models.map((model) => ({ + id: model.id, + label: model.label, + isDefault: model.isDefault === true, + efforts: [] + })), + current: { model: 'claude-gone' } + } + ) + expect(structuredAgentSessionListedPicks(fallback, undefined, {})).toBeNull() + }) + + it.each<[string, unknown[]]>([ + ['a null row', [null]], + ['a row with no efforts', [{ id: 'opus[1m]', label: 'Opus', isDefault: false }]], + ['no rows', []] + ])('keeps the model when the host answer has %s', (_name, models) => { + const live = stopped('claude-gone') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed host answer is the case under test. + const answer = { origin: 'probe', fetchedAt: 1, models } as AgentSessionModelCatalogResult + expect(structuredAgentSessionListedPicks(withHostList(live, answer), undefined, {})).toBeNull() + }) + + it('keeps the model before any host answer', () => { + expect(structuredAgentSessionListedPicks(fresh(), remembered, {})).toBeNull() + expect(structuredAgentSessionListedPicks(stopped('claude-gone'), undefined, {})).toBeNull() + }) +}) + +describe('what a send carries', () => { + const carried = ( + state: StructuredAgentSessionOptionState, + seed?: Readonly>, + picks: Readonly> = {} + ) => + structuredAgentSessionSendSelection( + state.record.agent, + structuredAgentSessionOptionSnapshot( + structuredAgentSessionOptionView(state, seed, picks, true) + ) + ) + + it('carries the shown model and effort, the replacement included', () => { + expect(carried(withHostList(), remembered)).toEqual({ model: 'opus[1m]', effort: 'xhigh' }) + // A chat that still runs a listed model carries that one. + const listed = { ...LISTED, models: [OPUS, SONNET] } + expect(carried(withHostList(stopped('sonnet', [OPUS, SONNET]), listed))).toEqual({ + model: 'sonnet', + effort: 'high' + }) + }) + + it('carries the default it shows, and a model it keeps while the list is unproven', () => { + const named = { ...SONNET, isDefault: true } + const listing = { ...LISTED, models: [OPUS, named], listingNamesConfiguredModel: true } + expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' }) + const listing2: AgentSessionModelCatalogResult = { ...LISTED, listingInProgress: true } + expect(carried(withHostList(stopped('claude-gone'), listing2))).toEqual({ + model: 'claude-gone' + }) + }) + + it('resolves a pick on its way as the picker shows it, a gone model it names included', () => { + const listed = withHostList(fresh(), { ...LISTED, models: [OPUS, SONNET] }) + expect(carried(listed, remembered, { model: 'sonnet' })).toMatchObject({ model: 'sonnet' }) + expect(carried(withHostList(), undefined, { model: 'claude-retired' })).toMatchObject({ + model: 'opus[1m]' + }) + expect(carried(withHostList(), remembered, { effort: 'low' })).toEqual({ + model: 'opus[1m]', + effort: 'low' + }) + }) + + it('carries nothing before a model shows, or for another agent', () => { + expect(carried(fresh())).toBeUndefined() + const codex = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex', CLAUDE_SESSION_OPTION_CATALOG), + CLAUDE_SESSION_OPTION_CATALOG, + { models: [OPUS], current: { model: 'opus[1m]' } } + ) + expect(carried(codex)).toBeUndefined() + }) + + it("shows the phone's picker on the replacement too, only for a host that takes it", () => { + const model = (takes: boolean) => + structuredAgentSessionShownSnapshot(withHostList(stopped('claude-gone')), takes).find( + (descriptor) => descriptor.id === 'model' + )?.kind + expect(model(true)).toMatchObject({ currentValue: 'opus[1m]' }) + expect(model(false)).toMatchObject({ currentValue: 'claude-gone' }) + }) +}) + +describe('nearestAgentEffort', () => { + it('keeps an offered level, takes the higher on a tie, and names none it cannot rank', () => { + expect(nearestAgentEffort('high', ['low', 'high'])).toBe('high') + expect(nearestAgentEffort('medium', ['low', 'high'])).toBe('high') + expect(nearestAgentEffort('max', ['low', 'xhigh'])).toBe('xhigh') + expect(nearestAgentEffort('turbo', ['low', 'high'])).toBeUndefined() + }) +}) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 15d6ec7800d..a762c6add75 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -1,7 +1,9 @@ import { commitStructuredAgentSessionOptionValues, + structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from './structured-agent-session-options' +import type { SessionOptionDescriptor } from './native-chat-session-options' import { cloneNativeChatSessionOptionRecord } from './native-chat-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' @@ -16,23 +18,39 @@ function catalogListsSeedModel( /** * What the picker shows before the host has confirmed this session's values: * `seed` (the selection a launch seeds) stands in until the record names a - * model, and `held` picks outrank both until the host settles them. Both show - * as `dispatched`; derived on every read, never written into the record. + * model, and `held` picks outrank both until the host settles them, as does what a send moves + * a model the verified list lacks to. All show as `dispatched`; derived on every read, never + * written into the record. * * The permission mode reads its own way: the held pick, else what the host stated, else the seed. + * A host that cannot take a send's model keeps running the chat's own, so that is what shows. */ export function structuredAgentSessionOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, - held: Readonly> + held: Readonly>, + takesSendOptions: boolean ): StructuredAgentSessionOptionState { const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: heldPermission, ...heldSelection } = held - const view = modelOptionView(state, seed, heldSelection) + const replaced = takesSendOptions + ? structuredAgentSessionListedPicks(state, seed, heldSelection) + : null + const view = modelOptionView(state, seed, { ...heldSelection, ...replaced }) const permissionMode = heldPermission ?? state.permissionMode ?? seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? null return view.permissionMode === permissionMode ? view : { ...view, permissionMode } } +/** The picker of a client that holds no launch selection, such as the phone. */ +export function structuredAgentSessionShownSnapshot( + state: StructuredAgentSessionOptionState, + takesSendOptions: boolean +): SessionOptionDescriptor[] { + return structuredAgentSessionOptionSnapshot( + structuredAgentSessionOptionView(state, undefined, {}, takesSendOptions) + ) +} + function modelOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, @@ -66,3 +84,81 @@ function modelOptionView( } return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } } + +const EFFORT_RANK = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] + +/** `previous` if `supported` offers it, else the nearest offered level (the higher on a tie). */ +export function nearestAgentEffort( + previous: string, + supported: readonly string[] +): string | undefined { + const rank = (level: string): number => EFFORT_RANK.indexOf(level) + const distance = (level: string): number => Math.abs(rank(level) - rank(previous)) + if (supported.includes(previous)) { + return previous + } + if (rank(previous) === -1) { + return undefined + } + return supported + .filter((level) => rank(level) !== -1) + .sort((a, b) => distance(a) - distance(b) || rank(b) - rank(a))[0] +} + +/** What a send moves a Claude chat to when the host's finished listing does not name its model + * (held, else recorded, else seeded): the listed row marked default, else the first, with the + * effort moved to the nearest level offered. Null keeps the model. */ +export function structuredAgentSessionListedPicks( + state: StructuredAgentSessionOptionState, + seed: Readonly> | undefined, + held: Readonly> +): Record | null { + const { catalog, catalogSource, record, verifiedModelIds: verified = [] } = state + const tracked = record.model?.value + const model = held.model ?? (typeof tracked === 'string' ? tracked : seed?.model) + // Only the listing proves a row: the session's own unlisted model is shown but never listed. + const rows = catalog?.models.filter((row) => verified.includes(row.id)) ?? [] + if ( + record.agent !== 'claude' || + (catalogSource !== 'host' && catalogSource !== 'live') || + !model || + rows.length === 0 || + verified.includes(model) + ) { + return null + } + const recorded = record.valuesByModel[model]?.effort?.value + const effort = + held.effort ?? + (typeof recorded === 'string' ? recorded : seed?.model === model ? seed.effort : undefined) + const target = rows.find((row) => row.isDefault) ?? rows[0]! + const levels = target.options.find((option) => option.id === 'effort')?.kind + const carried = + effort && levels?.type === 'select' + ? nearestAgentEffort( + effort, + levels.choices.map((choice) => choice.value) + ) + : undefined + return { model: target.id, ...(carried ? { effort: carried } : {}) } +} + +/** A pick on its way to the host. */ +export type StructuredAgentSessionPendingPick = { id: string; value: string } + +/** The Claude model and effort the composer shows (`snapshot`), which every send carries. */ +export function structuredAgentSessionSendSelection( + agent: string, + snapshot: readonly SessionOptionDescriptor[] +): Record | undefined { + if (agent !== 'claude') { + return undefined + } + const shown = (id: string): string | undefined => { + const kind = snapshot.find((descriptor) => descriptor.id === id)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined + } + const model = shown('model') + const effort = shown('effort') + return model ? { model, ...(effort ? { effort } : {}) } : undefined +} diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index b9fb51bac85..15dd34315b4 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -8,13 +8,18 @@ import { applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, settleStructuredAgentSessionBuiltinCatalog, - structuredAgentSessionOptionSnapshot + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState } from './structured-agent-session-options' import { structuredAgentSessionOptionView } from './structured-agent-session-option-view' -function viewModel(...args: Parameters) { +function viewModel( + state: StructuredAgentSessionOptionState, + seed: Readonly> | undefined, + held: Readonly> +) { const model = structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView(...args) + structuredAgentSessionOptionView(state, seed, held, true) ).find((descriptor) => descriptor.id === 'model') return model?.kind.type === 'select' ? model.kind.currentValue : undefined } @@ -279,7 +284,7 @@ describe('structured agent session options', () => { // A saved pick the host's list does not name shows the quiet placeholder, never its raw id. expect( structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView(seeded, { model: 'gpt-next' }, {}) + structuredAgentSessionOptionView(seeded, { model: 'gpt-next' }, {}, true) ) ).toEqual([expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' })]) // Before the host answers, and on the built-in list, no saved pick is named. diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 5920e68ae17..d9996213d41 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -51,6 +51,9 @@ export type StructuredAgentSessionOptionState = { * built-in list before the host answered (shown as the quiet placeholder); `builtin` is the * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null + /** The models the host's latest answer names when it is a finished, well-formed listing: the + * only rows that can stand in for a model it lacks, whatever list the picker shows. */ + verifiedModelIds?: readonly string[] record: NativeChatSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null @@ -77,7 +80,40 @@ export function createStructuredAgentSessionOptionState( export function settleStructuredAgentSessionBuiltinCatalog( state: StructuredAgentSessionOptionState ): StructuredAgentSessionOptionState { - return state.catalogSource === 'seed' ? { ...state, catalogSource: 'builtin' } : state + const settled = withVerifiedModelIds(state, undefined) + return settled.catalogSource === 'seed' ? { ...settled, catalogSource: 'builtin' } : settled +} + +function withVerifiedModelIds( + state: StructuredAgentSessionOptionState, + ids: readonly string[] | undefined +): StructuredAgentSessionOptionState { + return String(state.verifiedModelIds) === String(ids) + ? state + : { ...state, verifiedModelIds: ids } +} + +function isListedModelRow(row: unknown): row is { id: string } { + return ( + typeof row === 'object' && + row !== null && + 'id' in row && + typeof row.id === 'string' && + 'efforts' in row && + Array.isArray(row.efforts) + ) +} + +/** The ids a finished listing names; undefined for one still running, failed, empty or malformed. */ +function finishedListingIds(catalog: AgentSessionModelCatalogResult): string[] | undefined { + const rows: unknown = catalog.origin === 'unknown' ? null : catalog.models + return Array.isArray(rows) && + rows.length > 0 && + rows.every(isListedModelRow) && + !catalog.listingInProgress && + !catalog.unavailable + ? rows.map((row) => row.id) + : undefined } /** @@ -92,8 +128,9 @@ export function applyStructuredAgentSessionModelCatalog( catalog: AgentSessionModelCatalogResult, options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { + const verifiedModelIds = finishedListingIds(catalog) if (state.catalogSource === 'live') { - return state + return withVerifiedModelIds(state, verifiedModelIds) } const models = catalog.origin === 'unknown' @@ -118,7 +155,8 @@ export function applyStructuredAgentSessionModelCatalog( models, ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, - catalogSource: 'host' + catalogSource: 'host', + verifiedModelIds } } diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index 93f981857db..18b40ae0cd7 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -44,7 +44,12 @@ describe('the permission mode picker', () => { 'codex', structuredAgentSessionSeedCatalog('codex') ) - const view = structuredAgentSessionOptionView(state, { permissionMode: 'full-access' }, {}) + const view = structuredAgentSessionOptionView( + state, + { permissionMode: 'full-access' }, + {}, + true + ) expect(structuredAgentSessionOptionSnapshot(view).map((descriptor) => descriptor.id)).toEqual([ 'model', 'permissionMode' @@ -107,7 +112,7 @@ describe('the permission mode picker', () => { { newLaunch: true } ) const descriptor = permission( - structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {}) + structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {}, true) ) return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) } @@ -128,11 +133,13 @@ describe('the permission mode picker', () => { it("shows a held pick over the host's value, and the host's over the seed", () => { const state = live('codex', { permissionMode: 'auto' }) const seed = { permissionMode: 'ask' } - expect(permission(structuredAgentSessionOptionView(state, seed, {}))).toMatchObject({ + expect(permission(structuredAgentSessionOptionView(state, seed, {}, true))).toMatchObject({ kind: { currentValue: 'auto' } }) expect( - permission(structuredAgentSessionOptionView(state, seed, { permissionMode: 'full-access' })) + permission( + structuredAgentSessionOptionView(state, seed, { permissionMode: 'full-access' }, true) + ) ).toMatchObject({ kind: { currentValue: 'full-access' } }) }) diff --git a/src/shared/structured-agent-session-send-mutation.test.ts b/src/shared/structured-agent-session-send-mutation.test.ts index 353186e4da1..031cfc773f3 100644 --- a/src/shared/structured-agent-session-send-mutation.test.ts +++ b/src/shared/structured-agent-session-send-mutation.test.ts @@ -58,6 +58,37 @@ describe('send fingerprints never cover the sender', () => { expect(withSender.body.from).toEqual(FROM) }) + it('carries options in the operation fingerprint only; without them the send is unchanged', () => { + const send = (options?: Record) => + structuredAgentSessionMessageSendMutation({ + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + body: BODY, + ...(options ? { options } : {}) + }) + const plain = send() + // An older host gets exactly today's shape and fingerprint. + expect(plain).not.toHaveProperty('options') + expect(plain.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: BODY } + }) + ) + const carried = send({ model: 'opus', effort: 'high' }) + expect(carried.options).toEqual({ model: 'opus', effort: 'high' }) + expect(carried.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: BODY, options: { model: 'opus', effort: 'high' } } + }) + ) + expect(SendParams.safeParse({ ...carried, body: BODY }).success).toBe(true) + }) + it('refuses a client send whose body names a sender', () => { const params = (body: unknown) => ({ envelope: { diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index aec8ad3988e..73dd8ca8270 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -4,10 +4,32 @@ import type { z } from 'zod' import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -export type StructuredAgentSessionSendMutation = { +/** What a send carries beside its message: both join the OPERATION fingerprint, never the body's. */ +export type StructuredAgentSessionSendControl = { + delivery?: 'queue-if-active' + options?: Readonly> +} + +export type StructuredAgentSessionSendMutation = StructuredAgentSessionSendControl & { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem - delivery?: 'queue-if-active' +} + +/** The control fields a send names, as the client sends them and the host digests them. */ +export function agentSessionSendControlFields( + send: StructuredAgentSessionSendControl +): StructuredAgentSessionSendControl { + return { + ...(send.delivery ? { delivery: send.delivery } : {}), + ...(send.options ? { options: send.options } : {}) + } +} + +/** What a send's operation fingerprint covers, on the client and the host alike. */ +export function agentSessionSendFields( + send: StructuredAgentSessionSendControl & { body: AgentJournalMessageItem } +): StructuredAgentSessionSendControl & { body: AgentJournalMessageItem } { + return { body: agentSessionMessagePayload(send.body), ...agentSessionSendControlFields(send) } } /** What every send fingerprint covers: the message without its sender, which never reaches the @@ -31,15 +53,15 @@ export function agentSessionSendBodyFingerprint( /** The `agentSession.send` arguments for one message. Typed rather than wire-shaped so a host * calling its own send path builds the same envelope a client would, fingerprint included. */ -export function structuredAgentSessionMessageSendMutation(message: { - sessionId: string - clientOperationId: string - expectedRuntimeFence: number - body: AgentJournalMessageItem - delivery?: 'queue-if-active' -}): StructuredAgentSessionSendMutation { - // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. - const delivery = message.delivery ? { delivery: message.delivery } : {} +export function structuredAgentSessionMessageSendMutation( + message: { + sessionId: string + clientOperationId: string + expectedRuntimeFence: number + body: AgentJournalMessageItem + } & StructuredAgentSessionSendControl +): StructuredAgentSessionSendMutation { + const control = agentSessionSendControlFields(message) return { envelope: { sessionId: message.sessionId, @@ -48,11 +70,11 @@ export function structuredAgentSessionMessageSendMutation(message: { payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: message.sessionId, - fields: { body: agentSessionMessagePayload(message.body), ...delivery } + fields: agentSessionSendFields(message) }) }, body: message.body, - ...delivery + ...control } }