mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+43
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user