fix: a Claude model that's gone switches to the list's default when you send (#27826)

* fix: start a new Claude chat on the listed default when its remembered model is gone

The remembered new-chat model was launched as-is even after Claude stopped
offering it, so every new chat failed its first message. The host create and
the desktop new-chat picker now derive the same replacement from the host's
saved model list: the listed default, effort moved to the nearest level it
offers. Explicit models (--model, held picks) run as given; with no saved list
the remembered model is kept.

* fix: read the host's model list only for a Claude create that needs it

The check read host.deps before ruling out other agents and explicit models,
so creates whose host carries no deps (and every non-Claude create) paid for
a lookup they never use.

* fix: picker derives the fallback only where the host would

A held model runs as picked on the host, so the picker no longer derives an
effort beside it. A cached list whose default marker was dropped for this
workspace keeps the placeholder instead of naming its first row.

* fix: the new-chat picker picks the replacement for a gone Claude model

The host-side check read the saved list separately from the picker, so the
two disagreed and a cold start never fired it. The desktop new-chat picker
now picks the replacement it shows, once, through the existing pick path,
so the first send runs it and the remembered selection heals. The host is
back to main.

* fix: decide a gone Claude model's replacement at send, from the list in hand

The open-time auto-pick raced the chat's create and the list's arrival, so
the first send could still launch the gone model. Now, like the common
pattern, the picker shows the replacement for a model the host's loaded
list lacks, and the send step applies it through the existing pick call,
awaited, right before the message goes out. A failed pick never holds the
send back.

* fix: one verified list decides a gone model's replacement

The picker, the send step and the option setter now all read the list the
picker shows, less the row a session adds for its own unlisted model, so
what is shown is what applies and an explicit pick from that list stays.
A listing that is still running, failed, or absent keeps the model. The
send step re-derives from the chat's state when it runs, so a second send
behind one that already moved the model applies nothing.

* fix: only rows a finished host listing names can replace a gone model

A single verified flag outlived the catalog it described: live options built
from the built-in fallback list, or a malformed or empty host answer, still
authorized a replacement the host would refuse. The state now keeps the ids
a finished, well-formed host listing names, and a replacement must be both
shown in the picker and named there.

* fix(native-chat): the send carries the composer's Claude model and effort

Every composer send (desktop and phone) carries the model and effort the
picker shows, as optional agentSession.send options, only to a host that
advertises agent-session.send-options.v1. The host writes a carried value
that differs from the chat's own through the existing pick path before
accepting the message, so the turn starts on it and the chat records it.
A queued card keeps its options and applies them when it goes out; a
launch's create call carries the replacement for its first message.

Replaces the separate awaited pick before each send.

* chore: drop an unrelated lockfile change

* refactor(native-chat): one helper names a send's control fields for client and host

* ci: rerun against current main

* fix(native-chat): a message runs on the model and effort it was sent with

The carried selection is now stored with the message (its submission row,
and a queued card's row) and applied through the shared option writer as
the delivery loop hands the message over, or before the start it needs.
A pick made after the message was accepted no longer changes the model it
runs on. Direct sends, queue drains and Send now share that one apply
point; the early apply before acceptance and the queue-only apply are gone.

The picker and the send read one effective selection: the replacement for
a model the host's finished listing lacks shows only for a host that takes
a send's selection, the phone validates picks against what it shows, a
send carries the default the composer shows, a resumed launch's first
message gets the same resolution, and only the listing proves a row (no
label heuristic).

* docs(native-chat): a submission's options are the host's alone

* fix(native-chat): an opening message keeps its model; a pending pick resolves like the picker

The opening message a create sends now stores the model and effort the chat
was created with, so a pick made before its handover no longer changes what
it runs on, and a card kept after a restart keeps them.

A pick on its way to the host resolves through the same option view the
picker shows before a send carries it, so a gone model it names is replaced
too; the send selection reads only the shown snapshot.

The option writer builds its own record-and-publish context, so the message
path no longer duplicates it, and the client and host share one function for
a send's fingerprint fields.

* ci: rerun against current main
This commit is contained in:
Brennan Benson
2026-10-13 21:21:47 -07:00
committed by GitHub
parent 6741f4f165
commit 04b4e962a7
62 changed files with 1395 additions and 141 deletions
@@ -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',
@@ -91,6 +91,7 @@ const HOST_SUPPORT = {
queuedCommands: false,
statusFeed: true,
quietRepeatedStop: true,
sendOptions: false,
conversationStop: false
}
@@ -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 })
@@ -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)
}
}
@@ -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<Record<string, string>>
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
@@ -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
@@ -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
}
@@ -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
@@ -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<boolean>
invokeStructuredOption: (id: string) => Promise<boolean>
}
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<SessionOptionsSurface>(
@@ -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)),
@@ -125,6 +125,7 @@ function Harness({
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false,
sendOptions: false,
conversationStop: false
},
onSendError: vi.fn()
@@ -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 }
@@ -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<string, string>
let agent = 'codex'
let hostSupport: StructuredAgentSessionHostSupport | null = null
const onSendError = vi.fn()
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const subscribe = vi.fn<RpcClient['subscribe']>((_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 () => {
@@ -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(
() => ({
@@ -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
@@ -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
@@ -111,6 +111,7 @@ export class JournalQueuedMessages {
hostInstance: string
requireAttachments?: true
holdReason?: QueuedMessageHoldReason
options?: Readonly<Record<string, string>>
},
receipt?: JournalOperationReceipt
): Promise<QueuedMessageRow> {
@@ -90,6 +90,7 @@ export function journalSubmissionRowBuilder(
handoverRecorded?: true
queuedMessageId?: string
origin?: 'client' | 'host'
options?: Readonly<Record<string, string>>
},
/** 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<AgentSessionMessageSource, 'kind'>
options?: Readonly<Record<string, string>>
}): 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 } : {})
}
}
@@ -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<Record<string, string>>
}
export type JournalSubmissionOrigin = 'client' | 'host'
@@ -106,6 +106,8 @@ export type JournalSubmissionInput = {
origin?: 'client' | 'host'
/** Who it is from (`JournalSubmissionRow.source`); the row keeps the kind only. */
source?: Pick<AgentSessionMessageSource, 'kind'>
/** The model and effort it runs on (`JournalSubmissionRow.options`). */
options?: Readonly<Record<string, string>>
}
/** A submission append that converts a queued draft, in one transaction. */
@@ -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.
@@ -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
@@ -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)
);
`)
@@ -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<string, string> } {
if (json === null) {
return {}
}
try {
const options: unknown = JSON.parse(json)
return isAgentSessionOptions(options) ? { options } : {}
} catch {
return {}
}
}
@@ -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<Record<string, string>>
}
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<Record<string, string>>
}
): 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 } : {})
}
}
@@ -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<Record<string, string>> | undefined): {
options?: Readonly<Record<string, string>>
} {
const { model, effort } = options ?? {}
return model ? { options: { model, ...(effort ? { effort } : {}) } } : {}
}
@@ -53,6 +53,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = {
sessionId: string,
startedFor: string
) => Promise<StructuredAgentSessionResumeOutcome>
/** A message's model and effort, applied before its agent starts or it is handed over. */
applyMessageOptions: (
sessionId: string,
options: AgentJournalSubmission['options']
) => Promise<void>
/** 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,
@@ -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<StructuredAgentSessionResumeOutcome>
/** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */
stopOutgrownAgent: (sessionId: string) => Promise<void>
/** 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) => {
@@ -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<Record<string, string>>
/** 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)
)
@@ -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, {
@@ -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<TurnOutcome<AgentSessionOptionResult>> {
const { sessionId, fence } = target
const session = context.sessions.get(sessionId)
const ctx = {
sessionId,
fence,
adapter: context.deps.adapter,
persistOptions: (options: Readonly<Record<string, string>>) =>
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<Record<string, string>> | undefined
): Promise<void> {
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
})
}
}
}
@@ -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<AgentSessionSendResult> {
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<AgentSessionSendResult> {
// 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
@@ -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<Record<string, string>>
}
export function eventually(assertion: () => void | Promise<void>): Promise<void> {
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,
@@ -181,6 +181,7 @@ export async function maybeQueueStructuredAgentSessionSend(
envelope: { clientOperationId: string }
body: AgentJournalMessageItem
delivery?: 'queue-if-active'
options?: Readonly<Record<string, string>>
/** 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
},
@@ -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
},
@@ -14,6 +14,7 @@ export async function runQueueableStructuredAgentSessionSend(
envelope: { clientOperationId: string }
body: AgentJournalMessageItem
delivery?: 'queue-if-active'
options?: Readonly<Record<string, string>>
userSend?: true
personsMessage?: true
},
@@ -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<Record<string, string>> | undefined {
return rig.store.getRecord(SESSION)?.options
}
async function handedOver(id: string): Promise<void> {
await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined())
}
/** A running child with nothing in flight. */
async function idleRunningChild(): Promise<void> {
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<ReturnType<typeof createRestTestRig>>,
options: Record<string, string> = 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<string, string>) => {
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<Record<string, string>>): 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()
}
})
})
@@ -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<TurnOutcome<AgentSessionOptionResult>> {
@@ -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<Record<string, string>>
}
): Promise<TurnOutcome<AgentSessionSendResult>> {
const existing = ctx.journal
@@ -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)],
@@ -10,6 +10,7 @@ export type StructuredAgentSessionPendingSend = {
previewUris: readonly string[]
queuedAt: number
delivery?: 'queue-if-active'
options?: Readonly<Record<string, string>>
/**
* `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.
@@ -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<AgentSessionSendResult>
let answer: StructuredAgentSessionSendAnswer
@@ -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<Record<string, string>>
/** 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)
@@ -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<string | null>(null)
// A pick on its way to the host, which every send carries too.
const pendingOptionRef = useRef<StructuredAgentSessionPendingPick | null>(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<Record<string, string>> | undefined,
held: Readonly<Record<string, string>>
) => structuredAgentSessionOptionView(state, seed, held, takesSendOptions),
[takesSendOptions]
)
return {
optionView,
takesSendOptions,
conversationSupport,
rereadOptions,
optionState,
@@ -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<boolean> => {
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<SessionOptionsSurface>(
() => ({
@@ -312,6 +307,7 @@ export function useStructuredAgentSessionOptions(args: {
optionSnapshot,
optionSurface,
setStructuredOption,
sendSource: { optionStateRef, pendingOptionRef, optionView, takesSendOptions },
unavailable: hostCatalog.unavailable
}
}
@@ -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<string, string> | 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()
})
})
@@ -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<typeof useStructuredAgentSessionOptionState>,
'optionStateRef' | 'pendingOptionRef' | 'optionView' | 'takesSendOptions'
>
): () => Readonly<Record<string, string>> | 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])
}
@@ -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<Record<string, string>> | 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 {
@@ -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({
@@ -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<unknown>>(),
supports: vi.fn<() => Promise<boolean>>(),
admit: vi.fn<() => Promise<HostCreateSupport>>(),
inventory: vi.fn()
}))
const mocks = vi.hoisted(() => {
const settings: Record<string, unknown> = {}
return {
call: vi.fn<(target: unknown, method: string, params: unknown) => Promise<unknown>>(),
supports: vi.fn<() => Promise<boolean>>(),
admit: vi.fn<() => Promise<HostCreateSupport>>(),
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<unknown>()
mocks.call.mockReturnValue(create.promise)
@@ -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<Record<string, string>> | 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,
@@ -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
@@ -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(
@@ -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<Record<string, string>>
}
/** Durable answer to "did my send land?", keyed by client message id. Only an
@@ -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()
@@ -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')
@@ -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<typeof structuredAgentSessionOptionView>) {
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<Record<string, string>>,
picks: Readonly<Record<string, string>> = {}
) =>
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()
})
})
@@ -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<Record<string, string>> | undefined,
held: Readonly<Record<string, string>>
held: Readonly<Record<string, string>>,
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<Record<string, string>> | 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<Record<string, string>> | undefined,
held: Readonly<Record<string, string>>
): Record<string, string> | 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<string, string> | 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
}
@@ -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<typeof structuredAgentSessionOptionView>) {
function viewModel(
state: StructuredAgentSessionOptionState,
seed: Readonly<Record<string, string>> | undefined,
held: Readonly<Record<string, string>>
) {
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.
+41 -3
View File
@@ -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
}
}
@@ -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' } })
})
@@ -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<string, string>) =>
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: {
@@ -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<Record<string, string>>
}
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
}
}