fix(chat): keep resumed Claude messages deliverable after settings reports (#30982)

* fix(chat): separate applied Claude choices from provider reports

* fix(chat): preserve Max effort without settings readback

* test(chat): retain Max effort confirmation contract

* test(chat): a literal model change no longer pre-flights the catalog

* fix(chat): record observed execution independently of accepted choices

* fix(chat): fence model reports at prompt input before recording

* test(chat): inspect Fast evidence without unsafe outcome access

* test(chat): honor input preparation in turn fixtures

* test(claude): accept dispatch metadata after stopping a turn

* fix: retire deferred chat options at input preparation
This commit is contained in:
Brennan Benson
2026-10-20 19:50:18 -07:00
committed by GitHub
parent 5999642ff1
commit d98b8eddb4
33 changed files with 602 additions and 275 deletions
@@ -189,6 +189,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
return rejectedAcpDispatch(session)
}
const { sessionId, fence, signal } = input
signal?.throwIfAborted()
input.onInputPreparation?.()
const optionsApplied = await applyAcpMessageOptions(
session,
input.executionOptions,
@@ -558,7 +558,7 @@ describe('Claude stream-json connection', () => {
const session = {
connection,
options: new Map<string, string>(),
reusableOptions: new Map<string, string>(),
appliedInputOptions: new Map<string, string>(),
reportedOptions: {},
startup: { state: 'proven' }
} as unknown as ClaudeSession
@@ -128,7 +128,7 @@ describe('Claude model before the first turn', () => {
// The settings read follows `started`; what it shows is persisted as the later report.
const reported = events.find((event) => event.type === 'options-reported')
expect(reported).toMatchObject({ reportedOptions: { model: 'haiku' } })
expect(reported).toMatchObject({ reportedOptions: { model: 'claude-haiku-4-5-20251001' } })
})
it('shows the applied effort but never persists it as the session options', async () => {
@@ -136,7 +136,7 @@ describe('Claude model before the first turn', () => {
const adapter = await acquired(startedWithoutATurn(SETTINGS.noOverride), {}, events)
const started = await claudeOpened(adapter, 'session-1')
expect(started).toMatchObject({ reportedOptions: { model: 'opus[1m]' } })
expect(started.reportedOptions).not.toHaveProperty('model')
// Saved, it would be restored on every reopen past a later settings.json change.
expect(started.reportedOptions).not.toHaveProperty('effort')
const reported = events.find((event) => event.type === 'options-reported')
@@ -174,7 +174,7 @@ describe('Claude model before the first turn', () => {
// `started` is persisted as the session's options; opus's effort must not ride along onto haiku.
const started = await claudeOpened(adapter, 'session-1')
expect(started).toMatchObject({ reportedOptions: { model: 'haiku' } })
expect(started.reportedOptions).not.toHaveProperty('model')
expect(started.reportedOptions.effort).toBeUndefined()
})
})
@@ -29,7 +29,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
reusableOptions: new Map(),
appliedInputOptions: new Map(),
launchedModel: null,
fastModeAtStart: false,
capabilities: [],
@@ -29,7 +29,7 @@ function sessionWith(
optionMutationSequence: 0,
reportedModelMutation: 0,
confirmedOptions: new Set<string>(),
reusableOptions: new Map(),
appliedInputOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
@@ -195,7 +195,7 @@ describe('Claude effort against the model that must run it', () => {
})
it('sends `max`, which the readback cannot report, when the model advertises it', async () => {
// UNREPORTED_EFFORTS still governs: no get_settings, so no false disagreement.
// The underlying setting may differ from a transient execution effort.
const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] })
await expect(
@@ -203,7 +203,7 @@ describe('Claude effort against the model that must run it', () => {
).resolves.toEqual({ model: 'sonnet', effort: 'max' })
expect(calls).toEqual(['list_models', 'apply:max'])
await readClaudeStructuredSessionOptions(session, undefined)
expect(session.reusableOptions.get('effort')).toBe('max')
expect(session.appliedInputOptions.get('effort')).toBe('max')
expect(session.confirmedOptions.has('effort')).toBe(false)
})
@@ -0,0 +1,53 @@
import type { ClaudeSession } from './claude-structured-session-state'
/** Provider observations only; requested choices and picker defaults cannot vouch for a run. */
export function claudeExecutionObservations(session: ClaudeSession): Record<string, string> {
const model =
session.reportedModelMutation === session.optionMutationSequence
? (session.reportedOptions.model ?? session.appliedOptions?.model)
: session.appliedOptions?.model
const effort = session.appliedOptions?.effort
const fast =
session.fastModeReportedMutation === session.optionMutationSequence &&
session.fastModeState === 'on' &&
session.reportedOptions.fastMode !== false
return {
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(fast ? { fastMode: 'true' } : {})
}
}
/** Retire affected evidence before a control can change the child, even if its answer is lost. */
export function beginClaudeOptionMutation(session: ClaudeSession, key: string): number {
const previous = session.optionMutationSequence++
const sequence = session.optionMutationSequence
if (key === 'model') {
delete session.appliedOptions
} else {
if (session.reportedModelMutation === previous) {
session.reportedModelMutation = sequence
}
if (key === 'effort' && session.appliedOptions) {
const { effort: _old, ...applied } = session.appliedOptions
session.appliedOptions = applied
}
}
if (key === 'model' || key === 'fastMode') {
delete session.fastModeReportedMutation
} else if (session.fastModeReportedMutation === previous) {
session.fastModeReportedMutation = sequence
}
return sequence
}
/** An init names the model that runs; a control answer only acknowledges its input. */
export function observeClaudeExecutionModel(session: ClaudeSession, model: string): boolean {
const previous = claudeExecutionObservations(session).model
if (previous !== model) {
delete session.appliedOptions
}
session.reportedOptions.model = model
session.reportedModelMutation = session.optionMutationSequence
return previous !== model
}
@@ -0,0 +1,176 @@
import { expect, it } from 'vitest'
import {
acquired,
claudeFrame,
fakeClaude,
USER_MESSAGE
} from './claude-structured-session-test-support'
import { recordAgentSessionRanOptions } from '../runtime/agent-session-record-options'
import {
createQueuedMessageTestRig,
eventually
} from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture'
import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
it.each(['low', 'high', undefined])(
'admits accepted high independently of resolved effort (effective %s)',
async (effective) => {
const models = [
{
value: 'sonnet',
supportsEffort: true,
supportedEffortLevels: ['low', 'high'],
supportsFastMode: true
}
]
const claude = fakeClaude({
initModels: models,
routes: { list_models: () => models },
settings: {
...(effective ? { applied: { model: 'sonnet', effort: 'low' } } : {}),
effective: { effortLevel: effective, fastMode: false }
}
})
const adapter = await acquired(claude)
const rig = await createQueuedMessageTestRig()
const choices = { model: 'sonnet', effort: 'high', fastMode: 'true' } as const
try {
rig.dispatch.mockImplementation((input) =>
adapter.dispatch({ ...input, sessionId: 'session-1', fence: 7 })
)
const first = rig.send('first', undefined, { carried: choices })
expect(await first.result).toMatchObject({ ok: true })
await eventually(() =>
expect(rig.store.getRecord(SESSION)?.options).toEqual({
model: 'sonnet',
...(effective ? { effort: 'low' } : {})
})
)
const outcome = await rig.dispatch.mock.results[0]!.value
expect(outcome).toEqual({
state: 'admitted',
ranOptions: effective ? { model: 'sonnet', effort: 'low' } : {}
})
expect(outcome.ranOptions).not.toHaveProperty('fastMode')
const record = rig.store.getRecord(SESSION)!
expect(
recordAgentSessionRanOptions(
{ ...record, options: { model: 'sonnet', effort: 'high' } },
{
sessionId: SESSION,
fence: record.lease.runtimeFence,
model: 'sonnet',
options: outcome.ranOptions!,
models: undefined,
now: record.updatedAt
}
).options
).toEqual({ model: 'sonnet', effort: 'high' })
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'again',
body: USER_MESSAGE,
executionOptions: choices,
fence: 7
})
expect(
claude.connections[0]!.calls.filter(
(call) =>
call.subtype === 'apply_flag_settings' && JSON.stringify(call.params).includes('high')
)
).toHaveLength(1)
expect(claude.connections[0]!.sent).toHaveLength(2)
} finally {
await adapter.closeAll()
await rig.dispose()
}
}
)
it('retires earlier Fast routing when an effort read reports Fast off', async () => {
let fastMode = true
const models = [
{
value: 'sonnet',
supportsEffort: true,
supportedEffortLevels: ['low', 'high'],
supportsFastMode: true
}
]
const claude = fakeClaude({
initModels: models,
routes: { list_models: () => models },
settings: {
applied: { model: 'sonnet', effort: 'low' },
get effective() {
return { effortLevel: 'high', fastMode }
}
}
})
const adapter = await acquired(claude)
const send = (effort: string) =>
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: effort,
body: USER_MESSAGE,
executionOptions: { model: 'sonnet', effort, fastMode: 'true' },
fence: 7
})
try {
await send('high')
claudeFrame(claude.connections[0]!, {
type: 'system',
subtype: 'status',
fast_mode_state: 'on'
})
fastMode = false
expect(await send('low')).toMatchObject({ state: 'admitted', ranOptions: { effort: 'low' } })
expect(await send('low')).toEqual({ state: 'admitted', ranOptions: { effort: 'low' } })
} finally {
await adapter.closeAll()
}
})
it('never pairs an earlier unknown-model low run with the next prepared model', async () => {
const applied: { model?: string; effort: string } = { effort: 'low' }
const claude = fakeClaude({ initModel: '', settings: { applied } })
const events: Parameters<typeof acquired>[2] = []
const adapter = await acquired(claude, {}, events)
const rig = await createQueuedMessageTestRig()
try {
rig.dispatch.mockImplementation((input) =>
adapter.dispatch({ ...input, sessionId: 'session-1', fence: 7 })
)
const first = rig.send('first')
await first.result
await eventually(async () =>
expect((await rig.submission(first.id))?.handedOverAt).toBeDefined()
)
expect(await rig.dispatch.mock.results[0]!.value).toEqual({
state: 'admitted',
ranOptions: { effort: 'low' }
})
expect(rig.store.getRecord(SESSION)?.options).toBeUndefined()
await rig.settleAccepted(first.id, 'a')
claude.routes.set_model = async () => {
claudeFrame(claude.connections[0]!, { type: 'system', subtype: 'init', model: 'B' })
const report = events.findLast((event) => event.type === 'options-reported')!
await rig.host.handleAdapterEvent({
...report,
sessionId: SESSION,
fence: rig.store.getRecord(SESSION)!.lease.runtimeFence,
acquisitionGeneration: 'generation-1'
})
expect(rig.store.getRecord(SESSION)?.options).toBeUndefined()
Object.assign(applied, { model: 'B', effort: 'high' })
}
await rig.send('second', undefined, { carried: { model: 'B', effort: 'high' } }).result
await eventually(() =>
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'B', effort: 'high' })
)
expect(claude.connections[0]!.sent).toHaveLength(2)
} finally {
await adapter.closeAll()
await rig.dispose()
}
})
@@ -296,14 +296,13 @@ describe('Claude structured turn timing', () => {
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] },
fence: 7
})
await Promise.resolve()
await dispatch
const connection = claude.connections[0]!
connection.handlers.onMessage?.({
...connection.sent[0],
uuid: 'turn-1',
timestamp: '2001-01-01T00:00:00.000Z'
})
await dispatch
connection.handlers.onMessage?.({
type: 'assistant',
uuid: 'assistant-1',
@@ -1,8 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import {
acquired,
adapterFor,
claudeFrame,
fakeClaude,
identityFor,
recordingJournalSink,
USER_MESSAGE
} from './claude-structured-session-test-support'
import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options'
@@ -26,6 +29,85 @@ const MODELS = [
]
describe('Claude accepted message choices', () => {
it.each([false, true])(
'delivers resumed choices despite optional reports (alias listed: %s)',
async (listed) => {
const saved = { model: 'opus[1m]', effort: 'high', fastMode: 'false' } as const
const models = listed ? [...MODELS, { value: saved.model }] : MODELS
const claude = fakeClaude({ initModels: models, routes: { list_models: () => models } })
const adapter = adapterFor(claude, { resumesTranscript: true })
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'resume',
options: saved,
events: recordingJournalSink()
})
const connection = claude.connections[0]!
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' })
connection.calls.length = 0
for (const clientMessageId of ['restart', 'followup']) {
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId,
body: USER_MESSAGE,
executionOptions: saved,
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
}
expect(
connection.calls.filter((call) =>
['set_model', 'apply_flag_settings'].includes(call.subtype)
)
).toEqual([])
for (const clientMessageId of ['changed', 'unchanged']) {
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId,
body: USER_MESSAGE,
executionOptions: { ...saved, model: 'custom[1m]', effort: 'low' },
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
}
expect(
connection.calls.filter((call) =>
['set_model', 'apply_flag_settings'].includes(call.subtype)
)
).toEqual([
{ subtype: 'set_model', params: { model: 'custom[1m]' } },
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'low' } } }
])
const failure = new Error('control refused')
connection.setModel = async () => {
throw failure
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: `refused-${listed}`,
body: USER_MESSAGE,
executionOptions: { model: 'another' },
fence: 7
})
).resolves.toMatchObject({ state: 'rejected' })
expect(connection.sent).toHaveLength(4)
expect(warn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ error: failure })
)
} finally {
warn.mockRestore()
}
}
)
it.each([
['the effort the CLI applied over the settings level', 'low', { effort: 'low' }],
['no effort where the CLI applies none, whatever the settings say', null, {}]
@@ -57,7 +139,10 @@ describe('Claude accepted message choices', () => {
executionOptions: {},
fence: 7
})
expect(outcome).toEqual({ state: 'admitted', ranOptions })
expect(outcome).toEqual({
state: 'admitted',
ranOptions: applied ? { model: 'claude-sonnet-5', ...ranOptions } : {}
})
})
it('applies a held message A before its prompt, then message B on the existing child', async () => {
@@ -1,12 +1,20 @@
import { claudeExecutionObservations } from './claude-structured-execution-observations'
import { dispatchClaudeTurn } from './claude-structured-dispatch'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
createStructuredAgentSessionLogger,
neverThrowingStructuredAgentSessionLogger,
type StructuredAgentSessionLogger
} from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { listedModels } from './claude-structured-model-catalog'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './claude-structured-session-state'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
const defaultLogger = createStructuredAgentSessionLogger()
/** Applies the accepted message's choices while its prompt is still in the SDK input queue. */
export async function applyClaudeMessageOptions(
session: ClaudeSession,
@@ -16,7 +24,7 @@ export async function applyClaudeMessageOptions(
): Promise<void> {
throwIfSignalAborted(signal)
const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal))
if (model && session.reusableOptions.get('model') !== model) {
if (model && session.appliedInputOptions.get('model') !== model) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs),
signal
@@ -25,28 +33,24 @@ export async function applyClaudeMessageOptions(
throwIfSignalAborted(signal)
const effort = options.effort ?? session.configuredDefaults?.effort
if (effort && session.reusableOptions.get('effort') !== effort) {
if (effort && session.appliedInputOptions.get('effort') !== effort) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs),
signal
)
} else if (!effort && session.options.has('effort')) {
// Omission restores configuration; without its value a live control cannot clear the override.
throw new AgentSessionOptionRejectedError('Claude did not report its configured effort')
}
throwIfSignalAborted(signal)
const fastMode = options.fastMode
if (fastMode !== undefined && session.reusableOptions.get('fastMode') !== fastMode) {
if (fastMode !== undefined && session.appliedInputOptions.get('fastMode') !== fastMode) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs),
signal
)
}
for (const [key, value] of Object.entries({ model, effort, fastMode })) {
if (value !== undefined && session.reusableOptions.get(key) !== value) {
throw new AgentSessionOptionRejectedError(`Claude did not apply ${key} ${value}`)
}
}
throwIfSignalAborted(signal)
}
@@ -74,50 +78,45 @@ async function defaultModelForMessage(
return fallback
}
/** What the turn runs on, from the CLI's own answers: the message's choices it applied, and the
* effort the CLI resolved for the model it applies (never the settings level alone). */
function claudeRanOptions(
session: ClaudeSession,
options: AgentSessionExecutionOptions
): Record<string, string> {
const resolvedHere = options.model === undefined || options.model === session.launchedModel
const ran = {
model: options.model,
effort: options.effort ?? (resolvedHere ? session.configuredDefaults?.effort : undefined),
fastMode: options.fastMode
}
return Object.fromEntries(
Object.entries(ran).flatMap(([key, value]) =>
value !== undefined && session.reusableOptions.get(key) === value ? [[key, value]] : []
)
)
}
export async function dispatchClaudeMessage(
session: ClaudeSession,
input: Parameters<StructuredAgentSessionAdapter['dispatch']>[0],
timeoutMs: number | undefined
timeoutMs: number | undefined,
logger: StructuredAgentSessionLogger = defaultLogger
): ReturnType<StructuredAgentSessionAdapter['dispatch']> {
const messageOptions = input.executionOptions
const outcome = await dispatchClaudeTurn(
session,
input,
messageOptions === undefined
? input.beforeDispatch
: async () => {
await input.beforeDispatch?.()
try {
await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal)
input.signal?.throwIfAborted()
} catch (error) {
throw error instanceof AgentSessionOptionRejectedError
? error
: new AgentSessionOptionRejectedError(error)
}
let ranOptions: Record<string, string> = {}
const outcome = await dispatchClaudeTurn(session, input, async () => {
await input.beforeDispatch?.()
try {
input.signal?.throwIfAborted()
input.onInputPreparation?.()
if (messageOptions !== undefined) {
await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal)
}
input.signal?.throwIfAborted()
input.onExecutionSnapshot?.()
ranOptions = claudeExecutionObservations(session)
if (messageOptions?.model && ranOptions.model !== messageOptions.model) {
delete ranOptions.model
}
} catch (error) {
neverThrowingStructuredAgentSessionLogger(logger).warn(
'applying Claude message choices failed',
{
scope: 'claude-message-options',
sessionId: input.sessionId,
clientMessageId: input.clientMessageId,
error
}
)
)
throw error instanceof AgentSessionOptionRejectedError
? error
: new AgentSessionOptionRejectedError(error)
}
})
// Always, even empty: the settings effort the CLI reports is never evidence of a run.
return outcome.state === 'accepted' || outcome.state === 'admitted'
? { ...outcome, ranOptions: claudeRanOptions(session, messageOptions ?? {}) }
? { ...outcome, ranOptions }
: outcome
}
@@ -186,7 +186,7 @@ describe('Claude effort the settings readback cannot report', () => {
reportedOptions: {},
optionMutationSequence: 0,
confirmedOptions: new Set<string>(),
reusableOptions: new Map(),
appliedInputOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
@@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './claude-structured-session-state'
@@ -22,7 +21,7 @@ function sessionWith(catalog: readonly Record<string, unknown>[] | 'unavailable'
optionMutationSequence: 0,
reportedModelMutation: 0,
confirmedOptions: new Set<string>(),
reusableOptions: new Map(),
appliedInputOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
@@ -41,65 +40,16 @@ function sessionWith(catalog: readonly Record<string, unknown>[] | 'unavailable'
}
}
describe('Claude model pre-flight against the catalog the CLI listed', () => {
it('refuses a model the provider does not list', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined)
).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
// Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and
// every later turn returns is_error with zero tokens. Nothing undoes the
// write, so the refusal has to land before it.
expect(calls).toEqual(['list_models'])
expect(session.options.has('model')).toBe(false)
})
it('applies a model the provider lists', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined)
).resolves.toEqual({ model: 'haiku' })
expect(calls).toEqual(['list_models', 'set_model:haiku'])
})
it('applies a resolved model id the catalog carries only under its alias', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined)
).resolves.toEqual({ model: 'claude-sonnet-5' })
expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5'])
})
it('refuses nothing when list_models is unavailable', async () => {
// A CLI predating list_models would otherwise have every model refused.
const { session, calls } = sessionWith('unavailable')
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined)
).resolves.toEqual({ model: 'sonnet' })
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
})
it('refuses nothing when the listed catalog is empty', async () => {
// An empty answer identifies no model, so it is not evidence against one.
const { session, calls } = sessionWith([])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined)
).resolves.toEqual({ model: 'sonnet' })
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
})
it('refuses nothing when the catalog carries only the synthetic default row', async () => {
// listedModels drops that row, leaving a list that identifies no model.
const { session, calls } = sessionWith([DEFAULT_ROW])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined)
).resolves.toEqual({ model: 'sonnet' })
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
})
describe('Claude literal model controls', () => {
it.each(['opus[1m]', 'claude-sonnet-5'])(
'passes %s through an incomplete catalog',
async (model) => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: model }, undefined)
).resolves.toEqual({ model })
expect(calls).toEqual([`set_model:${model}`])
expect(session.appliedInputOptions.get('model')).toBe(model)
}
)
})
@@ -168,7 +168,7 @@ describe('confirmation never outlives the write it belongs to', () => {
reportedOptions: {},
optionMutationSequence: 0,
confirmedOptions: new Set<string>(),
reusableOptions: new Map(),
appliedInputOptions: new Map(),
connection: {
supportedModels: async () => CATALOG,
applyFlagSettings: async (s: { effortLevel?: string }) => {
@@ -38,7 +38,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
reusableOptions: new Map(),
appliedInputOptions: new Map(),
launchedModel: null,
fastModeAtStart: false,
capabilities: [],
@@ -296,6 +296,7 @@ describe('Claude structured Fast mode', () => {
setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined)
).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' })
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: false }, { timeoutMs: undefined })
expect(session.appliedInputOptions.get('fastMode')).toBe('false')
})
it('keeps Fast on across a model switch while support discovery is transient', async () => {
@@ -346,8 +347,8 @@ describe('Claude structured Fast mode', () => {
).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' })
expect(session.reportedOptions.fastMode).toBe(true)
expect(session.confirmedOptions.has('fastMode')).toBe(false)
expect(session.reusableOptions.get('model')).toBe('haiku')
expect(session.reusableOptions.has('fastMode')).toBe(false)
expect(session.appliedInputOptions.get('model')).toBe('haiku')
expect(session.appliedInputOptions.has('fastMode')).toBe(false)
})
it('treats an unrecognized provider disabled reason as unavailable', async () => {
+36 -51
View File
@@ -2,10 +2,10 @@ import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
claudeCatalogAdmitsModel,
claudeModelEffortLevels,
claudeModelFastModeSupport,
readClaudeCurrentModel,
observeClaudeSettingsApplied,
readClaudeListedModels,
readClaudeSettingsEffort,
readClaudeSettingsFastMode
@@ -13,15 +13,9 @@ import {
import type { ClaudeSession } from './claude-structured-session-state'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const
import { beginClaudeOptionMutation } from './claude-structured-execution-observations'
/**
* Efforts the settings readback cannot report. `max` applies for the rest of the
* session and is excluded from the persisted `effortLevel` by contract, so
* `get_settings` answers with the level underneath it — an absence of evidence
* that must not be read as the child refusing a level its own catalog offers.
*/
const UNREPORTED_EFFORTS: ReadonlySet<string> = new Set(['max'])
const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const
export function restoredClaudeStructuredSessionOptions(
options: Readonly<Record<string, string>> | undefined
@@ -81,7 +75,7 @@ export async function setClaudeStructuredOption(
// Two writes ask nothing of it and so read nothing: an effort write with no current
// model has nothing to look up, and turning Fast off needs no support evidence.
const needsCatalog =
input.key === 'model' ||
(input.key === 'model' && session.options.get('fastMode') === 'true') ||
(input.key === 'fastMode' && fastMode === true) ||
(input.key === 'effort' && readClaudeCurrentModel(session).id !== undefined)
const listed = needsCatalog ? await readClaudeListedModels(session, timeoutMs) : []
@@ -102,7 +96,7 @@ export async function setClaudeStructuredOption(
}
const support = claudeModelFastModeSupport(session, listed)
// A catalog that identified nothing is not evidence against this model, the same
// rule the admit-check below applies — otherwise a CLI that cannot answer has Fast
// rule used for unknown capability evidence — otherwise a CLI that cannot answer has Fast
// refused on every model. A catalog that did list the model and stayed silent
// about Fast is still not positive evidence, so that case keeps refusing.
if (fastMode && listed.length > 0 && support.supported !== true) {
@@ -120,42 +114,25 @@ export async function setClaudeStructuredOption(
)
}
}
// set_model resolves for a model the provider never lists and the session then
// fails every turn with zero tokens, so the acceptance proves nothing and only
// the catalog does.
if (input.key === 'model' && !claudeCatalogAdmitsModel(listed, input.value)) {
throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`)
}
const modelFastModeSupport =
input.key === 'model' && session.options.get('fastMode') === 'true'
? claudeModelFastModeSupport(session, listed, input.value)
: null
const modelWasConfirmed = readClaudeCurrentModel(session).confirmed
session.reusableOptions.delete(input.key)
if (input.key === 'model') {
session.reusableOptions.delete('effort')
session.reusableOptions.delete('fastMode')
}
const mutationSequence = ++session.optionMutationSequence
session.appliedInputOptions.delete(input.key)
const mutationSequence = beginClaudeOptionMutation(session, input.key)
// Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it.
const held = session.options.get(input.key)
// Only a model write can stale the model report — an effort or Fast write does
// not change what the child is running. Leaving the stamp behind
// would drop the session back to the written model and refuse, on the next
// effort write, a level the model actually running advertises.
if (modelWasConfirmed && input.key !== 'model') {
session.reportedModelMutation = mutationSequence
}
try {
await apply()
if (mutationSequence === session.optionMutationSequence) {
session.appliedInputOptions.set(input.key, input.value)
}
// Ahead of the fence checks: the child applied this write even if a newer one supersedes its bookkeeping.
if (input.key === 'model' && input.value !== held) {
session.translator?.modelMayHaveChanged()
}
if (input.key === 'model') {
session.translator?.modelWritten(input.value)
// It described the model this write replaced; the next readback re-reads it.
delete session.appliedOptions
}
if (
input.key === 'model' &&
@@ -165,13 +142,20 @@ export async function setClaudeStructuredOption(
if (mutationSequence !== session.optionMutationSequence) {
return Object.fromEntries(session.options)
}
session.reusableOptions.set('model', input.value)
session.options.set('model', input.value)
session.options.set('fastMode', 'false')
session.confirmedOptions.delete('effort')
session.confirmedOptions.delete('fastMode')
// The requested model is already accepted; a cleanup failure cannot reject that write.
await session.connection.applyFlagSettings({ fastMode: false }, { timeoutMs }).catch(() => {})
session.appliedInputOptions.delete('fastMode')
await session.connection.applyFlagSettings({ fastMode: false }, { timeoutMs }).then(
() => {
if (mutationSequence === session.optionMutationSequence) {
session.appliedInputOptions.set('fastMode', 'false')
}
},
() => {}
)
return Object.fromEntries(session.options)
}
} catch (error) {
@@ -180,29 +164,30 @@ export async function setClaudeStructuredOption(
}
throw error
}
// apply_flag_settings answers `success` for an effort it then ignores, so the
// absence of a throw proves nothing. Ask what the child actually holds.
const adopted =
(input.key === 'effort' && !UNREPORTED_EFFORTS.has(input.value)) || input.key === 'fastMode'
? await session.connection
.getSettings({ timeoutMs })
.then((settings) =>
input.key === 'fastMode'
? readClaudeSettingsFastMode(settings)
: readClaudeSettingsEffort(settings)
)
.catch(() => null)
// Readback confirms presentation; accepted input remains independent of optional reports.
// Max is session-only; settings readback reports the underlying level.
const settings =
(input.key === 'effort' && input.value !== 'max') || input.key === 'fastMode'
? await session.connection.getSettings({ timeoutMs }).catch(() => null)
: null
if (mutationSequence !== session.optionMutationSequence) {
return Object.fromEntries(session.options)
}
if (input.key === 'model' || (input.key === 'effort' && UNREPORTED_EFFORTS.has(input.value))) {
session.reusableOptions.set(input.key, input.value)
} else if (adopted !== null) {
session.reusableOptions.set(input.key, String(adopted))
if (settings !== null) {
observeClaudeSettingsApplied(session, settings)
if (readClaudeSettingsFastMode(settings) === false) {
delete session.fastModeReportedMutation
}
}
const adopted =
input.key === 'fastMode'
? readClaudeSettingsFastMode(settings)
: readClaudeSettingsEffort(settings)
if (input.key === 'fastMode' && typeof adopted === 'boolean') {
session.reportedOptions.fastMode = adopted
if (!adopted) {
delete session.fastModeReportedMutation
}
}
// A disagreement stops main vouching for the value, it does not veto the write:
// the pre-flight guard already refused levels the model advertises no control for,
@@ -12,7 +12,8 @@ import {
tick,
identityFor,
PROVIDER_SESSION_ID,
recordingJournalSink
recordingJournalSink,
USER_MESSAGE
} from './claude-structured-session-test-support'
const RETIRED = 'claude-retired-1'
@@ -154,6 +155,16 @@ describe('a live child still launched with a retired model', () => {
expect(setModelCalls(claude)).toEqual([{ subtype: 'set_model', params: { model: undefined } }])
const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(options.current.model).not.toBe(RETIRED)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'saved-choice',
body: USER_MESSAGE,
executionOptions: { model: RETIRED },
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
expect(setModelCalls(claude).map((call) => call.params?.model)).toEqual([undefined, RETIRED])
await adapter.closeAll()
})
@@ -2,6 +2,7 @@
// retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is
// `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run.
import { beginClaudeOptionMutation } from './claude-structured-execution-observations'
import type {
ClaudeSession,
ClaudeStructuredSessionAdapterDeps
@@ -37,7 +38,8 @@ export function resetClaudeRetiredModel(
sessionId: string,
retired: string
): void {
const sequence = session.optionMutationSequence
session.appliedInputOptions.delete('model')
const sequence = beginClaudeOptionMutation(session, 'model')
void session.connection.setModel(undefined, { timeoutMs: deps.requestTimeoutMs }).then(
() => {
if (sequence !== session.optionMutationSequence) {
@@ -18,6 +18,7 @@ import { claudeConfigDirEnvPatch } from './claude-config-dir-pin'
import { claudeProcessIdentity } from './claude-structured-owner-identity'
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options'
import { observeClaudeExecutionModel } from './claude-structured-execution-observations'
import { observeClaudeFastModeFacts } from './claude-structured-session-options'
import { createClaudeSessionPublication } from './claude-structured-session-publication'
import {
@@ -80,7 +81,6 @@ export async function acquireClaudeSession({
? liveSession.backgroundTasks.goalBackgroundWork
: 'unverifiable'
)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
@@ -91,23 +91,10 @@ export async function acquireClaudeSession({
}
return
}
const modelChanged =
liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false
if (init) {
initProof.resolve(init)
// Every turn opens with an init frame naming the model the CLI is actually
// running; set_model answers success for a model it never resolves, so this
// report is the session's only adoption evidence.
if (liveSession && init.model) {
if (liveSession.reusableOptions.get('model') !== init.model) {
liveSession.reusableOptions.delete('model')
liveSession.reusableOptions.delete('effort')
liveSession.reusableOptions.delete('fastMode')
}
if (liveSession.optionMutationSequence === 0) {
liveSession.reusableOptions.set('model', init.model)
}
liveSession.reportedOptions.model = init.model
liveSession.reportedModelMutation = liveSession.optionMutationSequence
}
if (liveSession) {
liveSession.capabilities = readClaudeCapabilities(liveSession.capabilities, init.message)
}
@@ -140,7 +127,16 @@ export async function acquireClaudeSession({
startsTurn || message.type === 'result' ? { observedAt: deps.now?.() ?? Date.now() } : {}
const requestedAt = turnOrigin?.requestedAt
const startedSend = !startsTurn && liveSession ? claudeStartedUnechoedSend(liveSession) : null
callbacks.deliver(attempt, sessionId, () =>
callbacks.deliver(attempt, sessionId, () => {
if (modelChanged && liveSession && init?.model) {
callbacks.emit(liveSession, input.events, {
type: 'options-reported',
sessionId,
fence: liveSession.fence,
acquisitionGeneration: liveSession.acquisitionGeneration,
reportedOptions: { model: init.model }
})
}
callbacks.emit(liveSession, input.events, {
type: 'message',
sessionId,
@@ -151,7 +147,7 @@ export async function acquireClaudeSession({
...(startedSend ? { startedSend } : {}),
...observedAt
})
)
})
for (const settle of settlements) {
settle()
}
@@ -103,11 +103,9 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => {
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
).resolves.toEqual({ model: 'sonnet' })
// The model write pre-flights the catalog first; this CLI lists nothing, which
// identifies no model and so refuses none. Then it asks for the new model's window.
expect(claude.connections[0].calls.slice(-4)).toEqual([
// A literal model goes straight to the CLI; then it asks for the new model's window.
expect(claude.connections[0].calls.slice(-3)).toEqual([
{ subtype: 'interrupt', params: {} },
{ subtype: 'list_models' },
{ subtype: 'set_model', params: { model: 'sonnet' } },
{ subtype: 'get_context_usage' }
])
@@ -55,11 +55,6 @@ export type {
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null {
const state = session.backgroundTasks.state
return state ? { ...state, supportsTaskStop: true } : null
}
export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, ClaudeSession>()
private readonly acquisitions = new ClaudeAcquisitionRegistry()
@@ -165,7 +160,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
dispatchClaudeMessage(
this.session(input.sessionId),
input,
this.deps.requestTimeoutMs,
this.deps.logger
)
// A rewind forks the kept conversation into a new session, which the next launch makes.
forkForRewind = claudeForkForRewind
@@ -207,7 +207,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
* read from the host's child records. */
backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => {
const session = this.sessions.get(sessionId)
return session ? backgroundTaskState(session) : undefined
const state = session?.backgroundTasks.state
return session && (state ? { ...state, supportsTaskStop: true } : null)
}
backgroundTaskStops: NonNullable<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
sessionId
@@ -54,22 +54,6 @@ export function observeClaudeSettingsApplied(session: ClaudeSession, settings: u
}
}
/** Applied effort describes execution; effective effort describes the underlying settings. */
export function observeClaudeSettingsExecutionEffort(
session: ClaudeSession,
settings: unknown
): void {
const applied = readClaudeSettingsApplied(settings)
const effort = applied ? applied.effort : readClaudeSettingsEffort(settings)
if (session.reusableOptions.get('effort') !== 'max') {
if (effort) {
session.reusableOptions.set('effort', effort)
} else if (applied) {
session.reusableOptions.delete('effort')
}
}
}
export function readClaudeSettingsFastMode(settings: unknown): boolean | null {
const value = record(record(settings)?.effective)?.fastMode
return typeof value === 'boolean' ? value : null
@@ -103,6 +87,7 @@ export function observeClaudeFastModeFacts(session: ClaudeSession, value: unknow
const facts = readClaudeFastModeFacts(value)
if (facts.state) {
session.fastModeState = facts.state
session.fastModeReportedMutation = session.optionMutationSequence
}
if (facts.disabledReason) {
session.fastModeDisabledReason = facts.disabledReason
@@ -208,22 +193,6 @@ function decodedFastMode(session: ClaudeSession): boolean | undefined {
return typeof decoded === 'boolean' ? decoded : undefined
}
/**
* Whether the catalog admits the model, matched by alias or resolved id so a pick
* stored as either one is found. The permissive case lives here rather than at the
* call site: every caller must treat an unidentified catalog the same way, and one
* that forgot would refuse every model on a CLI that cannot answer.
*/
export function claudeCatalogAdmitsModel(models: readonly ListedModel[], modelId: string): boolean {
// An empty list identifies no model, so it is not evidence against one — a live
// CLI predating `list_models` would otherwise have every model refused under it.
// Do not turn this into a refusal.
return (
models.length === 0 ||
models.some((model) => model.id === modelId || model.resolvedModel === modelId)
)
}
/** The built-in models a running child lists when the CLI gives it none; a chat at rest with no
* catalog lists the same. */
export function claudeFallbackModelOptions(): WireClaudeModel[] {
@@ -253,13 +222,14 @@ function observeClaudeSettingsReadback(
const fastMode = readClaudeSettingsFastMode(settings)
const perSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(settings)
observeClaudeSettingsApplied(session, settings)
observeClaudeSettingsExecutionEffort(session, settings)
if (effort) {
session.reportedOptions.effort = effort
}
if (fastMode !== null) {
session.reusableOptions.set('fastMode', String(fastMode))
session.reportedOptions.fastMode = fastMode
if (!fastMode) {
delete session.fastModeReportedMutation
}
if (decodedFastMode(session) !== undefined) {
session.options.set('fastMode', String(fastMode))
}
@@ -312,7 +282,6 @@ export function claudeStructuredSessionOptionsFrom(
readMutationSequence === session.optionMutationSequence
) {
session.options.set('fastMode', 'false')
session.reusableOptions.delete('fastMode')
session.confirmedOptions.delete('fastMode')
desiredFastMode = false
}
@@ -66,7 +66,7 @@ export function createClaudeSessionPublication(input: {
reportedModelMutation: 0,
confirmedOptions: new Set(),
// Only choices carried in this child's launch can skip their first write.
reusableOptions: new Map(
appliedInputOptions: new Map(
[...(input.options ?? [])].filter(([key]) => key === 'model' || key === 'effort')
),
launchedModel: null,
@@ -22,11 +22,11 @@ import {
claudeResolvedCatalogListing,
claudeStructuredSessionOptionsFrom,
observeClaudeSettingsApplied,
observeClaudeSettingsExecutionEffort,
readClaudeSettingsApplied,
readClaudeFastModeFacts,
readClaudeSettingsEffort
} from './claude-structured-session-options'
import { claudeExecutionObservations } from './claude-structured-execution-observations'
import type {
ClaudeAuthDiagnostic,
ClaudeSession,
@@ -126,18 +126,14 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial
const { init, initialization } = facts
// A turn's own init frame may already have reported the running model.
if (init?.model && session.reportedOptions.model === undefined) {
if (session.optionMutationSequence === 0) {
session.reusableOptions.set('model', init.model)
} else if (session.reusableOptions.get('model') !== init.model) {
session.reusableOptions.delete('model')
session.reusableOptions.delete('effort')
session.reusableOptions.delete('fastMode')
}
session.reportedOptions.model = init.model
session.reportedModelMutation = session.optionMutationSequence
}
const fastModeFacts = readClaudeFastModeFacts(initialization)
session.fastModeState ??= fastModeFacts.state
if (session.fastModeState === undefined && fastModeFacts.state) {
session.fastModeState = fastModeFacts.state
session.fastModeReportedMutation = 0
}
session.fastModeDisabledReason ??= fastModeFacts.disabledReason
session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init?.message)
// A catalog frame that streamed in after publish is newer than the initialize answer.
@@ -163,7 +159,6 @@ function applyClaudeSettingsFacts(
...(!session.options.has('effort') && applied?.effort ? { effort: applied.effort } : {})
}
}
observeClaudeSettingsApplied(session, settings)
if (published.fastModePerSessionOptIn !== null) {
session.fastModePerSessionOptIn = published.fastModePerSessionOptIn
}
@@ -171,7 +166,7 @@ function applyClaudeSettingsFacts(
session.events?.publish()
return
}
observeClaudeSettingsExecutionEffort(session, settings)
observeClaudeSettingsApplied(session, settings)
// The readback vouches for a value the child was launched with only when it reports that value.
const agrees = (key: string, reported: string): boolean =>
!session.options.has(key) || session.options.get(key) === reported
@@ -187,8 +182,10 @@ function applyClaudeSettingsFacts(
session.confirmedOptions.add('effort')
}
if (published.fastMode !== null) {
session.reusableOptions.set('fastMode', String(published.fastMode))
session.reportedOptions.fastMode = published.fastMode
if (!published.fastMode) {
delete session.fastModeReportedMutation
}
if (agrees('fastMode', String(published.fastMode))) {
session.confirmedOptions.add('fastMode')
}
@@ -210,10 +207,13 @@ function claudeStartedReportedOptions(
readMutationSequence
)
const listing = catalogListing ? { catalogListing } : {}
const { model: _projected, ...rest } = current
const model = claudeExecutionObservations(session).model
const observed = { ...rest, ...(model ? { model } : {}) }
if (session.options.has('effort') || session.reportedOptions.effort !== undefined) {
return { reportedOptions: current, ...listing }
return { reportedOptions: observed, ...listing }
}
const { effort: _displayOnly, ...persisted } = current
const { effort: _displayOnly, ...persisted } = observed
return { reportedOptions: persisted, ...listing }
}
@@ -170,6 +170,8 @@ export type ClaudeSession = {
appliedOptions?: { model?: string; effort?: string }
/** CLI settings resolved before a turn changed them, when no launch override hid that value. */
configuredDefaults?: { model?: string; effort?: string }
/** Mutation at which the provider last reported its Fast routing state. */
fastModeReportedMutation?: number
fastModeState?: AgentSessionFastModeState
fastModeDisabledReason?: string
fastModePerSessionOptIn?: boolean
@@ -178,8 +180,8 @@ export type ClaudeSession = {
reportedModelMutation: number
/** Options whose recorded value the provider reported, not merely accepted. */
confirmedOptions: Set<string>
/** Value-bound execution evidence, revoked before any possible write. */
reusableOptions: Map<string, string>
/** Inputs carried by launch or accepted controls, independent of provider reports. */
appliedInputOptions: Map<string, string>
/** The model the child was launched with, as `--model`; null when it runs the CLI's own. */
launchedModel: string | null
/** A launched model the CLI answered `model_not_found` for; reported until a new pick. */
@@ -1,4 +1,4 @@
// Saved choices seed the CLI launch; message choices are applied again before input.
// Saved choices seed the CLI launch; changed message choices apply before input.
import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
@@ -88,11 +88,18 @@ export function claudeStructuredSpawnOptions(input: {
/** The published session takes on what its child was launched with. */
export function adoptClaudeStructuredSpawnOptions(
session: Pick<ClaudeSession, 'translator' | 'launchedModel' | 'fastModeAtStart'>,
session: Pick<
ClaudeSession,
'translator' | 'launchedModel' | 'fastModeAtStart' | 'appliedInputOptions'
>,
spawn: Pick<ClaudeStructuredSpawnOptions, 'options' | 'fastModeAtStart'>
): void {
session.launchedModel = spawn.options.get('model') ?? null
session.fastModeAtStart = spawn.fastModeAtStart
const fastMode = spawn.options.get('fastMode')
if (fastMode !== undefined && !spawn.fastModeAtStart) {
session.appliedInputOptions.set('fastMode', fastMode)
}
const model = spawn.options.get('model')
if (model !== undefined) {
session.translator?.modelWritten(model)
@@ -198,11 +198,12 @@ describe('Claude turn ownership', () => {
})
expect(runningTurnId(bodies)).toBe('echo-turn')
const sendFirst = connection.send
connection.send = async (message) => {
connection.send = async (message, beforeDispatch) => {
if (connection.sent.length > 0) {
await beforeDispatch?.()
throw new Error('input pump stopped')
}
await sendFirst(message)
await sendFirst(message, beforeDispatch)
}
await expect(
@@ -240,6 +240,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
try {
await input.beforeDispatch?.()
input.signal?.throwIfAborted()
input.onInputPreparation?.()
const turnOptions = await waitForPromiseWithSignal(
resolvedCodexMessageOptions(session, input.executionOptions),
input.signal
@@ -287,6 +287,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
requestedAt?: number
/** Revalidate after preparation, immediately before writing to the provider. */
beforeDispatch?: () => Promise<void>
/** Local preparation boundary before choices can mutate the provider. */
onInputPreparation?: () => void
/** In-process checkpoint after applying choices, before capturing execution evidence. */
onExecutionSnapshot?: () => void
}): Promise<AgentSessionDispatchOutcome>
/** How this session narrows its agent's declared rewind; `agent` answers for one with no child
* running. The router applies the declaration first, so an adapter's answer never widens it. */
@@ -6,7 +6,7 @@ import {
providerDiagnosticOf
} from '../../../shared/agent-session-failure'
import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options'
import type { StructuredAgentSessionHandedOptions } from './structured-agent-session-ran-options'
import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options'
import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error'
import { abortSignalReason } from '../../../shared/abort-signal-reason'
import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts'
@@ -37,7 +37,7 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC
deliveryOrder?: StructuredAgentSessionDeliveryOrder
workChanged?: () => void
/** A message the agent took: a chat keeps, where it has none saved, the options it ran. */
recordRanOptions?: (handed: StructuredAgentSessionHandedOptions) => Promise<void>
recordRanOptions?: ReturnType<StructuredAgentSessionRanRecorder>
}
/** A handover whose pending row and abort handle are committed: the provider call runs outside the
@@ -212,7 +212,9 @@ async function dispatchSafely(
...(executionOptions ? { executionOptions } : {}),
...(signal ? { signal } : {}),
fence: ctx.fence,
requestedAt
requestedAt,
onInputPreparation: ctx.recordRanOptions?.prepareInput,
onExecutionSnapshot: ctx.recordRanOptions?.checkpoint
})
return signal?.aborted && outcome.state === 'rejected' ? cancelledDispatch() : outcome
} catch (error) {
@@ -30,7 +30,7 @@ import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-se
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row'
import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts'
import { recordStructuredAgentSessionRanOptions } from './structured-agent-session-ran-options'
import { createStructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options'
export type StructuredAgentSessionConversationDelivery = {
loop: StructuredAgentSessionDeliveryLoop
@@ -142,12 +142,11 @@ export function createStructuredAgentSessionConversationDelivery(input: {
stopping: input.clientDelivery.readStopping,
now: () => deps.now?.() ?? Date.now(),
acquireAborts: input.acquireAborts,
recordRanOptions: (sessionId, fence) => (handed) =>
recordStructuredAgentSessionRanOptions(
recordRanOptions: (sessionId, fence) =>
createStructuredAgentSessionRanRecorder(
{ deps, sessions, now: () => deps.now?.() ?? Date.now() },
sessionId,
fence,
handed
fence
)
})
const adoptOpened = async (
@@ -76,6 +76,11 @@ it('records what a later readback reports when the start named only a filled-in
expect(await first.result).toMatchObject({ ok: true })
await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1))
expect(rig.store.getRecord(SESSION)?.options).toBeUndefined()
await rig.settleAccepted(first.id, 'a')
const preparation = Promise.withResolvers<never>()
rig.dispatch.mockImplementationOnce(() => preparation.promise)
await rig.send('rejected before preparation').result
await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2))
await rig.host.handleAdapterEvent({
type: 'options-reported',
sessionId: SESSION,
@@ -83,6 +88,7 @@ it('records what a later readback reports when the start named only a filled-in
acquisitionGeneration: 'generation-1',
reportedOptions: { model: 'opus[1m]', effort: 'high' }
})
preparation.reject(new Error('content preparation failed'))
await eventually(() =>
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'opus[1m]', effort: 'high' })
)
@@ -195,3 +201,40 @@ it('steers a matching choice but holds a different one for a new turn', async ()
effort: 'low'
})
})
it.each(['before', 'during', 'after'])(
'uses only a model observed after input, including reports %s finalization',
async (when) => {
await rig.dispose()
rig = await createQueuedMessageTestRig({ reportsModel: 'old-model', reportsEffort: 'high' })
const report = (model: string) =>
rig.host.handleAdapterEvent({
type: 'options-reported',
sessionId: SESSION,
fence: rig.store.getRecord(SESSION)!.lease.runtimeFence,
acquisitionGeneration: 'generation-1',
reportedOptions: { model, effort: 'low', fastMode: true, fastModeState: 'on' }
})
rig.dispatch.mockImplementationOnce(async (input) => {
if (when === 'before') {
await report('previous-model')
}
input.onInputPreparation?.()
input.onExecutionSnapshot?.()
if (when === 'during') {
await report('actual-model')
}
return { state: 'admitted', ranOptions: {} }
})
const first = rig.send('first')
expect(await first.result).toMatchObject({ ok: true })
await eventually(() => expect(rig.dispatch).toHaveBeenCalledOnce())
if (when !== 'during') {
expect(rig.store.getRecord(SESSION)?.options).toBeUndefined()
await report('actual-model')
}
await eventually(() =>
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'actual-model' })
)
}
)
@@ -74,8 +74,12 @@ export async function noteStructuredAgentSessionReportedOptions(
}
const runs = childRuns.get(child)
childRuns.set(child, { reported: ran })
if (runs?.owed) {
await recordStructuredAgentSessionRanOptions(context, sessionId, fence, runs.owed)
if (runs?.owed && ran.model) {
const owed = runs.owed
await recordStructuredAgentSessionRanOptions(context, sessionId, fence, {
...owed,
...(owed.executed ? { executed: { ...owed.executed, model: ran.model } } : {})
})
}
}
@@ -83,14 +87,43 @@ export async function noteStructuredAgentSessionReportedOptions(
export type StructuredAgentSessionRanRecorder = (
sessionId: string,
fence: number
) => (handed: StructuredAgentSessionHandedOptions) => Promise<void>
) => ((handed: StructuredAgentSessionHandedOptions) => Promise<void>) & {
prepareInput?: () => void
checkpoint?: () => void
}
/** Keeps observation identity at input, so an early report is neither lost nor an old fallback. */
export function createStructuredAgentSessionRanRecorder(
context: RanOptionsContext,
sessionId: string,
fence: number
): ReturnType<StructuredAgentSessionRanRecorder> {
let checkpoint: { reported?: RanOptions } | undefined
return Object.assign(
(handed: StructuredAgentSessionHandedOptions) =>
recordStructuredAgentSessionRanOptions(context, sessionId, fence, handed, checkpoint),
{
prepareInput: () => {
const child = currentChild(context, sessionId, fence)
if (child) {
delete childRuns.get(child)?.owed
}
},
checkpoint: () => {
const child = currentChild(context, sessionId, fence)
checkpoint = { reported: child ? childRuns.get(child)?.reported : undefined }
}
}
)
}
/** After a turn was handed over; bookkeeping, so a failure is logged and never refuses the send. */
export async function recordStructuredAgentSessionRanOptions(
context: RanOptionsContext,
sessionId: string,
fence: number,
handed: StructuredAgentSessionHandedOptions
handed: StructuredAgentSessionHandedOptions,
checkpoint?: { reported?: RanOptions }
): Promise<void> {
const child = currentChild(context, sessionId, fence)
const { store, logger, agents } = context.deps
@@ -100,7 +133,10 @@ export async function recordStructuredAgentSessionRanOptions(
}
const { message, executed } = handed
const reported = childRuns.get(child)?.reported
const model = executed?.model ?? message?.model ?? reported?.model
const newerModel = checkpoint && reported !== checkpoint.reported ? reported?.model : undefined
const model = executed
? (executed.model ?? message?.model ?? newerModel)
: (message?.model ?? reported?.model)
childRuns.set(child, { ...(reported ? { reported } : {}), ...(model ? {} : { owed: handed }) })
if (!model) {
return
+5
View File
@@ -52,6 +52,11 @@ export async function dispatchPiRpcMessage(
)
}
}
input.signal?.throwIfAborted()
if (!isCurrent() || session.connection.closed) {
return exited()
}
input.onInputPreparation?.()
const model = input.executionOptions?.model ?? session.initialOptions.model
if (!input.executionOptions) {
input.signal?.throwIfAborted()