mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
Keep Chat UI model selections from snapping back to older replies (#40906)
* fix(native-chat): keep a model pick shown instead of an older answer's model A saved model pick on a live structured Claude chat snapped back: the host's options answer kept returning the model that answered before the pick, and the picker shows a reported model over the selection (for provider fallback). A pick now retires the child's earlier model reports until a turn carries that pick, as the common pattern does with a fallback report once the user picks again. The Claude adapter ignores old-execution frames until a turn applies the picked value; the host drops a report captured by an options read when a pick or a child change lands before the read answers. Picks still reach Claude at the next turn. * test(native-chat): cover the confirmed-init report under read freshness * test(native-chat): cover picks during recovery and legacy no-op mutations
This commit is contained in:
@@ -108,7 +108,8 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => running,
|
||||
conversation: async () => running
|
||||
conversation: async () => running,
|
||||
sessions: new Map([[SESSION, running]])
|
||||
} as unknown as StructuredAgentSessionMutationContext
|
||||
return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })
|
||||
}
|
||||
|
||||
@@ -41,11 +41,19 @@ export function beginClaudeOptionMutation(session: ClaudeSession, key: string):
|
||||
return sequence
|
||||
}
|
||||
|
||||
/** Until a turn carries the pick, the running process's answers name the model it replaced. */
|
||||
export function noteClaudeModelPick(session: ClaudeSession, model: string): void {
|
||||
session.modelPickAwaitingTurn = model
|
||||
delete session.reportedOptions.reportedModel
|
||||
}
|
||||
|
||||
/** An init names execution; a control answer only acknowledges its input. */
|
||||
export function observeClaudeExecutionModel(session: ClaudeSession, model: string): boolean {
|
||||
const previous = claudeExecutionObservations(session).model
|
||||
const previousResponse = session.reportedOptions.reportedModel
|
||||
session.reportedOptions.reportedModel = model
|
||||
if (!session.modelPickAwaitingTurn) {
|
||||
session.reportedOptions.reportedModel = model
|
||||
}
|
||||
if (previous !== model) {
|
||||
delete session.appliedOptions
|
||||
}
|
||||
@@ -93,7 +101,7 @@ function observeClaudeResponseModel(
|
||||
: null,
|
||||
responseModel: response
|
||||
}) ?? response
|
||||
if (model === session.reportedOptions.reportedModel) {
|
||||
if (session.modelPickAwaitingTurn || model === session.reportedOptions.reportedModel) {
|
||||
return false
|
||||
}
|
||||
session.reportedOptions.reportedModel = model
|
||||
|
||||
@@ -152,3 +152,50 @@ it('keeps a model picked during a turn over that turn’s later replies until a
|
||||
await adapter.closeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('retires the answered model on a saved pick until a turn carries that pick', async () => {
|
||||
const claude = fakeClaude({ initModel: 'claude-opus-5-5' })
|
||||
const adapter = await acquired(claude)
|
||||
const connection = claude.connections[0]!
|
||||
const read = async () => (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
|
||||
const answer = (uuid: string, model: string) =>
|
||||
claudeFrame(connection, {
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
message: { role: 'assistant', model, content: [] }
|
||||
})
|
||||
const turn = async (clientMessageId: string, model: string) => {
|
||||
claudeFrame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' })
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId,
|
||||
body: USER_MESSAGE,
|
||||
executionOptions: { model },
|
||||
fence: 7
|
||||
})
|
||||
}
|
||||
try {
|
||||
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5-5' })
|
||||
answer('fallback', 'claude-opus-4-8')
|
||||
expect((await read()).reportedModel).toBe('claude-opus-4-8')
|
||||
|
||||
adapter.noteOptionPick({ sessionId: 'session-1', fence: 7, key: 'model', value: 'sonnet' })
|
||||
answer('same-turn', 'claude-opus-4-8')
|
||||
expect((await read()).reportedModel).toBeUndefined()
|
||||
expect((await read()).confirmed ?? []).not.toContain('model')
|
||||
|
||||
// A message accepted before the pick runs the model it captured.
|
||||
await turn('queued-before-pick', 'opus')
|
||||
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5-5' })
|
||||
answer('queued-answer', 'claude-opus-5-5')
|
||||
expect((await read()).reportedModel).toBeUndefined()
|
||||
|
||||
await turn('carries-pick', 'sonnet')
|
||||
expect(connection.calls).toContainEqual({ subtype: 'set_model', params: { model: 'sonnet' } })
|
||||
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-sonnet-5-5' })
|
||||
answer('next-fallback', 'claude-opus-4-8')
|
||||
expect((await read()).reportedModel).toBe('claude-opus-4-8')
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { supportsClaudeStructuredLocation } from './launch/claude-structured-loc
|
||||
import * as claudeOptions from './launch/claude-structured-options'
|
||||
import { dispatchClaudeMessage } from './launch/claude-structured-message-options'
|
||||
import { readClaudeStructuredSessionOptions } from './launch/claude-structured-session-options'
|
||||
import { noteClaudeModelPick } from './claude-structured-execution-observations'
|
||||
import {
|
||||
ClaudeAcquisitionRegistry,
|
||||
type ClaudeSession,
|
||||
@@ -231,6 +232,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
deps: this.deps,
|
||||
emit: (session, event) => this.emit(session, event)
|
||||
})
|
||||
noteOptionPick: NonNullable<StructuredAgentSessionAdapter['noteOptionPick']> = (input) => {
|
||||
const session = this.sessions.get(input.sessionId)
|
||||
if (session?.fence === input.fence && input.key === 'model' && input.value) {
|
||||
noteClaudeModelPick(session, input.value)
|
||||
}
|
||||
}
|
||||
readOptions = (input: { sessionId: string; fence: number }) =>
|
||||
readClaudeStructuredSessionOptions(this.session(input.sessionId))
|
||||
releaseAcquisition = (input: ClaudeReleaseRequest): Promise<boolean> => {
|
||||
|
||||
@@ -135,6 +135,8 @@ export type ClaudeSession = {
|
||||
fastModePerSessionOptIn?: boolean
|
||||
/** Mutation sequence of the last model report; newer writes outrank it. */
|
||||
reportedModelMutation: number
|
||||
/** A saved model pick no turn of this process has carried; its reports predate the pick. */
|
||||
modelPickAwaitingTurn?: string
|
||||
/** `dispatchSequence` at the last model write; that turn's replies predate the pick. */
|
||||
modelMutationDispatch?: number
|
||||
/** Options whose recorded value the provider reported, not merely accepted. */
|
||||
|
||||
@@ -30,6 +30,10 @@ export async function applyClaudeMessageOptions(
|
||||
): Promise<void> {
|
||||
throwIfSignalAborted(signal)
|
||||
const model = options.model
|
||||
// Only a turn carrying the newest pick reports on it; earlier captured sends run the replaced model.
|
||||
if (model === session.modelPickAwaitingTurn) {
|
||||
delete session.modelPickAwaitingTurn
|
||||
}
|
||||
if (model && session.appliedInputOptions.get('model') !== model) {
|
||||
await waitForPromiseWithSignal(
|
||||
setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs),
|
||||
|
||||
@@ -213,6 +213,7 @@ export function claudeStructuredSessionOptionsFrom(
|
||||
const discovered = listedModels(catalog ? { models: catalog } : null)
|
||||
const listed = discovered.length > 0 ? discovered : seedModels()
|
||||
const current = readClaudeCurrentModel(session)
|
||||
const reportsPick = !session.modelPickAwaitingTurn
|
||||
const model = current.id === 'default' ? undefined : current.id
|
||||
const models = structuredAgentSessionOptionModels(listed, model, (row) => ({
|
||||
...row,
|
||||
@@ -230,7 +231,7 @@ export function claudeStructuredSessionOptionsFrom(
|
||||
session.reportedOptions.fastMode ??
|
||||
(session.fastModeState === undefined ? undefined : session.fastModeState !== 'off')
|
||||
const confirmed = [
|
||||
...(current.confirmed ? ['model'] : []),
|
||||
...(current.confirmed && reportsPick ? ['model'] : []),
|
||||
...(effort && session.confirmedOptions.has('effort') ? ['effort'] : []),
|
||||
...(fastMode !== undefined &&
|
||||
(session.confirmedOptions.has('fastMode') || !session.options.has('fastMode'))
|
||||
@@ -243,7 +244,7 @@ export function claudeStructuredSessionOptionsFrom(
|
||||
...(changesLive ? {} : { effortFixed: true as const }),
|
||||
current: {
|
||||
model,
|
||||
reportedModel: session.reportedOptions.reportedModel,
|
||||
reportedModel: reportsPick ? session.reportedOptions.reportedModel : undefined,
|
||||
reportedPermissionMode: session.reportedOptions.reportedPermissionMode,
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode !== undefined ? { fastMode } : {}),
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ export function piOptionRefreshHost(sessionId = 'session-1') {
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => conversation,
|
||||
conversation: async () => conversation
|
||||
conversation: async () => conversation,
|
||||
sessions: new Map([[sessionId, conversation]])
|
||||
} as unknown as StructuredAgentSessionMutationContext
|
||||
return readStructuredAgentSessionOptions(context, sessionId, { readsWithoutModel: true })
|
||||
},
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentSessionModelCatalogResult } from '../../../../../shared/agent-session/agent-session-wire'
|
||||
import type { StructuredAgentSessionMutationContext } from '../../operations/structured-agent-session-mutation-context'
|
||||
import type {
|
||||
StructuredAgentSessionHostSession,
|
||||
StructuredAgentSessionProviderChild
|
||||
} from '../structured-agent-session-host-types'
|
||||
import { readStructuredAgentSessionOptions } from '../../options/structured-agent-session-options-read'
|
||||
import { noteStructuredAgentSessionOptionPick } from '../../options/structured-agent-session-option-intent'
|
||||
import {
|
||||
hostTestCatalogReadOperation,
|
||||
hostTestOptionsReadStore,
|
||||
hostTestRestingOptionsJournal,
|
||||
hostTestRestingOptionsRecord,
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
QUEUED_RIG_CALLER
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import { appendUsageLimitedTurn } from './structured-agent-session-rewind-usage-limit.test-fixture'
|
||||
|
||||
const RESPONSE_REPORT = { model: 'opus[1m]', reportedModel: 'claude-opus-4-8' }
|
||||
// Claude's confirmed init model stands in for a report when no response named one.
|
||||
const CONFIRMED_INIT = { model: 'opus[1m]', confirmed: ['model'] }
|
||||
|
||||
function claudeOptionsRead(answer: Record<string, unknown> = RESPONSE_REPORT) {
|
||||
const record = {
|
||||
...hostTestRestingOptionsRecord('session-1'),
|
||||
provider: 'claude',
|
||||
options: { model: 'opus[1m]' }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader and pick counter use only the child's identity, fence and phase.
|
||||
const child = { fence: 2, phase: 'ready' } as unknown as StructuredAgentSessionProviderChild
|
||||
const conversation = {
|
||||
child,
|
||||
params: { provider: 'claude' },
|
||||
journal: hostTestRestingOptionsJournal()
|
||||
}
|
||||
// Picks serialize with the live read, so they can land only after it, as the answer is assembled.
|
||||
const entered = Promise.withResolvers<void>()
|
||||
const assembled = Promise.withResolvers<void>()
|
||||
const home: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models: [] }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: picks and the reader read only the session's current child.
|
||||
const session = { child } as unknown as StructuredAgentSessionHostSession
|
||||
const sessions = new Map([['session-1', session]])
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real reader accesses only these injected ports; no provider process is launched.
|
||||
const context = {
|
||||
deps: {
|
||||
adapter: {
|
||||
readOptions: async () => ({
|
||||
models: [],
|
||||
current: answer
|
||||
})
|
||||
},
|
||||
agents: { definition: () => null, capabilities: () => null },
|
||||
store: hostTestOptionsReadStore(() => record),
|
||||
modelCatalog: {
|
||||
captureRead: hostTestCatalogReadOperation,
|
||||
read: async () => home
|
||||
}
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => conversation,
|
||||
conversation: async () => {
|
||||
entered.resolve()
|
||||
await assembled.promise
|
||||
return conversation
|
||||
},
|
||||
sessions
|
||||
} as unknown as StructuredAgentSessionMutationContext
|
||||
const pick = (model: string) => {
|
||||
record.options.model = model
|
||||
noteStructuredAgentSessionOptionPick(
|
||||
{ deps: { adapter: {} }, sessions },
|
||||
{ sessionId: 'session-1', fence: 2 },
|
||||
{ key: 'model', value: model },
|
||||
true
|
||||
)
|
||||
}
|
||||
/** Run `during` while the read is between its live snapshot and its answer. */
|
||||
const read = async (during: () => void = () => {}) => {
|
||||
const answer = readStructuredAgentSessionOptions(context, 'session-1', {
|
||||
readsWithoutModel: true
|
||||
})
|
||||
await entered.promise
|
||||
during()
|
||||
assembled.resolve()
|
||||
return (await answer).current
|
||||
}
|
||||
return { read, pick, sessions, session }
|
||||
}
|
||||
|
||||
it('keeps a live fallback report that no pick has replaced', async () => {
|
||||
expect(await claudeOptionsRead(CONFIRMED_INIT).read()).toMatchObject({
|
||||
reportedModel: 'opus[1m]'
|
||||
})
|
||||
expect(await claudeOptionsRead().read()).toMatchObject({
|
||||
model: 'opus[1m]',
|
||||
reportedModel: 'claude-opus-4-8'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['picks back to the same model', ['sonnet', 'opus[1m]'], RESPONSE_REPORT],
|
||||
['re-picks the shown model', ['opus[1m]'], RESPONSE_REPORT],
|
||||
['picks back while only the init model confirms', ['sonnet', 'opus[1m]'], CONFIRMED_INIT]
|
||||
])(
|
||||
'drops a report captured before the user %s while the read is in flight',
|
||||
async (_, models, answer) => {
|
||||
const host = claudeOptionsRead(answer)
|
||||
const current = await host.read(() => models.forEach((model) => host.pick(model)))
|
||||
expect(current.model).toBe('opus[1m]')
|
||||
expect(current.reportedModel).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('drops a report from a child that ended or was replaced while the read was in flight', async () => {
|
||||
for (const next of [null, { fence: 3, phase: 'ready' }]) {
|
||||
const host = claudeOptionsRead()
|
||||
const current = await host.read(() => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only child identity matters to the reader.
|
||||
host.sessions.set('session-1', {
|
||||
...host.session,
|
||||
child: next
|
||||
} as unknown as StructuredAgentSessionHostSession)
|
||||
})
|
||||
expect(current.reportedModel).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'retires the report when the user picks back to it',
|
||||
QUEUED_RIG_CALLER,
|
||||
['sonnet', 'opus[1m]'],
|
||||
undefined
|
||||
],
|
||||
[
|
||||
'keeps the report through an old client empty no-op',
|
||||
{ ...QUEUED_RIG_CALLER, readsOptionsWithoutModel: false },
|
||||
[''],
|
||||
'claude-opus-4-8'
|
||||
]
|
||||
] as const)(
|
||||
'after a usage-limited turn, a pick made while recovery choices load %s',
|
||||
async (_, caller, models, reportedModel) => {
|
||||
const rig = await createQueuedMessageTestRig({ agent: 'claude' })
|
||||
try {
|
||||
const pick = async (value: string) => {
|
||||
const fields = { key: 'model', value }
|
||||
return rig.host.setOption(caller, {
|
||||
envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()),
|
||||
...fields
|
||||
})
|
||||
}
|
||||
expect(await pick('opus[1m]')).toMatchObject({ ok: true })
|
||||
rig.host.deps.adapter.readOptions = async () => ({ models: [], current: RESPONSE_REPORT })
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
|
||||
await appendUsageLimitedTurn(journal, NOW, 'quota')
|
||||
const recovering = Promise.withResolvers<void>()
|
||||
const released = Promise.withResolvers<undefined>()
|
||||
rig.host.deps.resolveLaunchEnv = () => {
|
||||
recovering.resolve()
|
||||
return released.promise
|
||||
}
|
||||
const answer = rig.host.readOptions(SESSION)
|
||||
await recovering.promise
|
||||
for (const model of models) {
|
||||
expect(await pick(model)).toMatchObject({ ok: true })
|
||||
}
|
||||
released.resolve(undefined)
|
||||
const { current } = await answer
|
||||
expect(current.model).toBe('opus[1m]')
|
||||
expect(current.reportedModel).toBe(reportedModel)
|
||||
} finally {
|
||||
await rig.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
+5
-1
@@ -55,7 +55,10 @@ import { openWithReadyStructuredAgentSessionChild } from './structured-agent-ses
|
||||
|
||||
import type { StructuredAgentSessionCaller } from '../host/structured-agent-session-host-types'
|
||||
import '../options/structured-agent-session-options-read'
|
||||
import { recordStructuredAgentSessionOptionIntent } from '../options/structured-agent-session-option-intent'
|
||||
import {
|
||||
noteStructuredAgentSessionOptionPick,
|
||||
recordStructuredAgentSessionOptionIntent
|
||||
} from '../options/structured-agent-session-option-intent'
|
||||
import { confirmIdleStructuredAgentSessionOption } from '../options/structured-agent-session-idle-option'
|
||||
|
||||
export function sendStructuredAgentSessionTurn(
|
||||
@@ -265,6 +268,7 @@ export async function setStructuredAgentSessionOption(
|
||||
readsWithoutModel: caller.readsOptionsWithoutModel !== false
|
||||
})
|
||||
if (picked.ok) {
|
||||
noteStructuredAgentSessionOptionPick(context, ctx, params, !noChoice)
|
||||
confirmIdleStructuredAgentSessionOption(context, ctx, params)
|
||||
}
|
||||
return picked
|
||||
|
||||
+35
@@ -19,6 +19,10 @@ import {
|
||||
} from './structured-agent-session-pick-reply'
|
||||
import type { StructuredAgentSessionAdapter } from '../providers/structured-agent-session-adapter'
|
||||
import { pickAgentSessionOption } from '../../../runtime/agent-session/records/agent-session-record-options'
|
||||
import type {
|
||||
StructuredAgentSessionHostSession,
|
||||
StructuredAgentSessionProviderChild
|
||||
} from '../host/structured-agent-session-host-types'
|
||||
|
||||
function acceptsOptionPick(
|
||||
agents: Pick<StructuredAgentRegistry, 'definition'>,
|
||||
@@ -103,3 +107,34 @@ export async function recordStructuredAgentSessionOptionIntent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelPicks = new WeakMap<StructuredAgentSessionProviderChild, number>()
|
||||
|
||||
/** Counts saved model picks per child, so a read can tell its captured report predates one. */
|
||||
export function structuredAgentSessionModelPicks(
|
||||
child: StructuredAgentSessionProviderChild
|
||||
): number {
|
||||
return modelPicks.get(child) ?? 0
|
||||
}
|
||||
|
||||
/** A saved pick retires the current child's earlier reports, including ones a read already captured. */
|
||||
export function noteStructuredAgentSessionOptionPick(
|
||||
context: {
|
||||
deps: { adapter: Pick<StructuredAgentSessionAdapter, 'noteOptionPick'> }
|
||||
sessions: ReadonlyMap<string, StructuredAgentSessionHostSession>
|
||||
},
|
||||
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'fence'>,
|
||||
input: { key: string; value: string },
|
||||
saved: boolean
|
||||
): void {
|
||||
const { sessionId, fence } = ctx
|
||||
// An old client's empty model clears nothing, so it retires no report.
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
const child = context.sessions.get(sessionId)?.child
|
||||
if (input.key === 'model' && child?.fence === fence) {
|
||||
modelPicks.set(child, structuredAgentSessionModelPicks(child) + 1)
|
||||
}
|
||||
context.deps.adapter.noteOptionPick?.({ sessionId, fence, ...input })
|
||||
}
|
||||
|
||||
+18
-10
@@ -30,12 +30,13 @@ import { structuredAgentSessionImmediatePermission } from './structured-agent-se
|
||||
import type { StructuredAgentSessionMutationContext } from '../operations/structured-agent-session-mutation-context'
|
||||
import { readStructuredSessionRecovery } from '../recovery/structured-agent-session-recovery-read'
|
||||
import { fillUnknownModelEfforts } from '../../../../shared/agent-session/options/agent-session-model-efforts'
|
||||
import { structuredAgentSessionModelPicks } from './structured-agent-session-option-intent'
|
||||
|
||||
/** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */
|
||||
export async function readStructuredAgentSessionOptions(
|
||||
context: Pick<
|
||||
StructuredAgentSessionMutationContext,
|
||||
'deps' | 'serialize' | 'openConversation' | 'conversation'
|
||||
'deps' | 'serialize' | 'openConversation' | 'conversation' | 'sessions'
|
||||
> &
|
||||
Partial<Pick<StructuredAgentSessionMutationContext, 'deliveryOrder'>>,
|
||||
sessionId: string,
|
||||
@@ -71,6 +72,7 @@ export async function readStructuredAgentSessionOptions(
|
||||
child && child.phase !== 'starting'
|
||||
? await adapter.readOptions?.({ sessionId, fence: child.fence })
|
||||
: undefined
|
||||
const modelPicks = child ? structuredAgentSessionModelPicks(child) : 0
|
||||
const scope = () =>
|
||||
structuredAgentSessionCatalogScopeOwned(
|
||||
context.deps,
|
||||
@@ -93,7 +95,8 @@ export async function readStructuredAgentSessionOptions(
|
||||
records,
|
||||
session,
|
||||
child,
|
||||
options: observed ?? null
|
||||
options: observed ?? null,
|
||||
modelPicks
|
||||
}
|
||||
})
|
||||
let started = await readCurrent(true)
|
||||
@@ -132,6 +135,7 @@ export async function readStructuredAgentSessionOptions(
|
||||
const floor = session.journal.context.floor()
|
||||
const contextFloor = floor ? { contextFloor: floor } : {}
|
||||
const restingOptions = await atRest()
|
||||
const recovery = await readStructuredSessionRecovery(session, context, sessionId)
|
||||
if (live && usesModelSpecificAcpEfforts(agent)) {
|
||||
options = {
|
||||
...options,
|
||||
@@ -159,12 +163,17 @@ export async function readStructuredAgentSessionOptions(
|
||||
const model = saved.model ?? (options.current.model || selected.current.model || displayDefault)
|
||||
const projected = live?.projectCurrent?.({ ...saved, ...(model ? { model } : {}) })
|
||||
const reportedEffort = projected?.confirmed?.includes('effort') ? projected.effort : undefined
|
||||
const reportedModel = live
|
||||
? (options.current.reportedModel ??
|
||||
(agent === 'claude' && options.current.confirmed?.includes('model')
|
||||
? options.current.model
|
||||
: undefined))
|
||||
: undefined
|
||||
// After every await: a pick or a replaced child since the live read retires its report.
|
||||
const reportedModel =
|
||||
live &&
|
||||
started.kind === 'live' &&
|
||||
context.sessions.get(sessionId)?.child === started.child &&
|
||||
started.modelPicks === structuredAgentSessionModelPicks(started.child)
|
||||
? (options.current.reportedModel ??
|
||||
(agent === 'claude' && options.current.confirmed?.includes('model')
|
||||
? options.current.model
|
||||
: undefined))
|
||||
: undefined
|
||||
const current = {
|
||||
...selected.current,
|
||||
...options.current,
|
||||
@@ -236,7 +245,6 @@ export async function readStructuredAgentSessionOptions(
|
||||
current.serviceTier = projected.options.serviceTier
|
||||
}
|
||||
}
|
||||
const recovery = await readStructuredSessionRecovery(session, context, sessionId)
|
||||
return {
|
||||
...options,
|
||||
...recovery,
|
||||
@@ -258,7 +266,7 @@ export async function readStructuredAgentSessionOptions(
|
||||
current: {
|
||||
...current,
|
||||
acceptedInputSources,
|
||||
...(reportedModel ? { reportedModel } : {}),
|
||||
reportedModel,
|
||||
...(reportedEffort !== undefined ? { reportedEffort } : {}),
|
||||
defaulted,
|
||||
confirmed,
|
||||
|
||||
+2
@@ -204,6 +204,8 @@ export class StructuredAgentSessionAdapterRouter implements Adapter {
|
||||
|
||||
canSetOptionWhileIdle: Adapter['canSetOptionWhileIdle'] = (input) =>
|
||||
this.liveOwnerOrNull(input.sessionId)?.canSetOptionWhileIdle?.(input) === true
|
||||
noteOptionPick: Adapter['noteOptionPick'] = (input) =>
|
||||
this.liveOwnerOrNull(input.sessionId)?.noteOptionPick?.(input)
|
||||
readAppliedInputOptions: Adapter['readAppliedInputOptions'] = (input) =>
|
||||
this.liveOwnerOrNull(input.sessionId)?.readAppliedInputOptions?.(input) ?? null
|
||||
|
||||
|
||||
@@ -304,6 +304,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
|
||||
): Promise<void | Readonly<Record<string, string>>>
|
||||
/** Opt in only when setOption can update idle metadata without a provider turn. */
|
||||
canSetOptionWhileIdle?(input: StructuredAgentSessionSetOptionInput): boolean
|
||||
/** A saved pick retires this child's earlier execution reports until a turn carries it. */
|
||||
noteOptionPick?(input: { sessionId: string; fence: number; key: string; value: string }): void
|
||||
/** Applied inputs for this exact child; observations never decide message admission. */
|
||||
readAppliedInputOptions?(input: {
|
||||
sessionId: string
|
||||
|
||||
Reference in New Issue
Block a user