Keep Claude approval choices across model changes without freezing delivery (#34031)

* fix(native-chat): keep Claude approval choices across model changes without freezing delivery

A Claude chat saved as "Approve for me" could reopen asking for every approval,
because Orca read the folder's saved model list as launch policy, and a model
change could keep running in a weaker approval mode Claude opened with. The
first fix for this was reverted after it froze the app: restored history left
a turn reading as running, a send was refused against it, and delivery retried
the same send with nothing changed.

- The saved approval choice goes to Claude as-is; the menu offers every mode.
- Orca records the mode Claude actually opened in. A send that picks a new
  model reopens Claude first when that mode differs from the one requested,
  only when the chat owes no work; Orca mail notices never reopen it.
- A message sent while a Claude turn is running joins that turn on its current
  choices; new choices apply at the next new turn. Nothing is refused.
- History catch-up closes every turn it opens, in the thread it opened in.
- At startup, a turn reading as running under the new agent that the agent is
  not running is ended as interrupted.
- Delivery goes round again only after a step shows what it changed (a commit,
  a different child, a start it joined ending, or a wake); otherwise it waits
  for the next change instead of repeating the same step.

Tracked in STA-10953.

* fix(native-chat): preserve execution evidence at dispatch

* Fix delivery iteration ownership and progress contracts

* Correct journal fixture types in delivery regressions

* Separate Claude input choices from error correlation
This commit is contained in:
Brennan Benson
2026-10-27 00:01:09 -07:00
committed by GitHub
parent 1c4d4a03bc
commit a0e7fa0619
40 changed files with 1067 additions and 544 deletions
@@ -282,7 +282,7 @@ function listingLandsLaterClient() {
}
describe('Approve for me on a phone chat opened before its account was listed', () => {
it('is offered once the listing lands', async () => {
it('is offered before and after the listing lands', async () => {
const host = listingLandsLaterClient()
const options = await mountOptions({
client: host.client,
@@ -290,10 +290,10 @@ describe('Approve for me on a phone chat opened before its account was listed',
optionStamp: '',
agent: 'claude'
})
expect(options.offered()).toEqual(['ask', 'accept-edits', 'full-access'])
expect(options.offered()).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
host.land()
await options.rerender({})
expect(options.offered()).toContain('auto')
expect(options.offered()).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
await options.unmount()
})
})
@@ -40,6 +40,7 @@ export type ClaudeJournalTranslator = {
commandInterruptRequested: (turnId: string) => void
/** Root work since cycle init makes the open turn eligible for folded sends. */
readonly openTurnInLiveProviderCycle: boolean
readonly openTurnHasInputWork: boolean
flush: () => void
childToolOwner?: ClaudeChildToolQueries['childToolOwner']
childActivity?: ClaudeChildToolQueries['childActivity']
+5
View File
@@ -35,6 +35,8 @@ export class ClaudeOpenTurn {
private reopenSuppressed = false
/** Track root work since cycle init; background output may open a turn before that init. */
private cycleWorkObserved = false
/** Input choices follow actual work; correlation also includes API errors. */
inputChoiceWorkObserved = false
private startedSend: ClaudeStartedSend | null = null
private readonly opener: (
frame: Record<string, unknown>,
@@ -96,6 +98,7 @@ export class ClaudeOpenTurn {
observeProviderCycleStart(): void {
this.cycleWorkObserved = false
this.inputChoiceWorkObserved = false
}
observeProviderCycleWork(): void {
@@ -147,6 +150,7 @@ export class ClaudeOpenTurn {
forgetCommand(turnId: string): void {
if (this.current?.command && this.current.turnId === turnId) {
this.usageLimitEvidence = null
this.inputChoiceWorkObserved = false
this.current = null
}
}
@@ -168,6 +172,7 @@ export class ClaudeOpenTurn {
this.usageLimitEvidence?.cause === 'quota'
this.suppressReopenOnFailure(quotaEnd)
this.cycleWorkObserved = false
this.inputChoiceWorkObserved = false
this.usageLimitEvidence = null
// End suppressed work even when no turn is open.
this.deps.endOpenWork(end.completedAt)
@@ -287,6 +287,7 @@ describe('answerClaudePrompt', () => {
session.translator = {
handle: vi.fn(),
openTurnInLiveProviderCycle: false,
openTurnHasInputWork: false,
journalPrompts: {
resolve: resolvePrompt,
handOver: () => () => {},
@@ -12,6 +12,10 @@ import {
} 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'
/** Claude ends a turn; a message sent before this joins the running turn on its choices. */
const claudeTurnEnds = (connection: Parameters<typeof claudeFrame>[0]) =>
claudeFrame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' })
it.each(['low', 'high', undefined])(
'admits accepted high independently of resolved effort (effective %s)',
async (effective) => {
@@ -40,11 +44,11 @@ it.each(['low', 'high', undefined])(
)
const first = rig.send('first', undefined, { carried: choices })
expect(await first.result).toMatchObject({ ok: true })
await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1))
await eventually(() =>
expect(rig.store.getRecord(SESSION)?.options).toEqual({
model: 'sonnet',
...(effective ? { effort: 'low' } : {})
})
expect(rig.store.getRecord(SESSION)?.options).toEqual(
effective ? { model: 'sonnet', effort: 'low' } : undefined
)
)
const outcome = await rig.dispatch.mock.results[0]!.value
expect(outcome).toEqual({
@@ -118,6 +122,7 @@ it('retires earlier Fast routing when an effort read reports Fast off', async ()
})
try {
await send('high')
claudeTurnEnds(claude.connections[0]!)
claudeFrame(claude.connections[0]!, {
type: 'system',
subtype: 'status',
@@ -125,6 +130,7 @@ it('retires earlier Fast routing when an effort read reports Fast off', async ()
})
fastMode = false
expect(await send('low')).toMatchObject({ state: 'admitted', ranOptions: { effort: 'low' } })
claudeTurnEnds(claude.connections[0]!)
expect(await send('low')).toEqual({ state: 'admitted', ranOptions: { effort: 'low' } })
} finally {
await adapter.closeAll()
@@ -152,6 +158,7 @@ it('never pairs an earlier unknown-model low run with the next prepared model',
})
expect(rig.store.getRecord(SESSION)?.options).toBeUndefined()
await rig.settleAccepted(first.id, 'a')
claudeTurnEnds(claude.connections[0]!)
claude.routes.set_model = async () => {
claudeFrame(claude.connections[0]!, { type: 'system', subtype: 'init', model: 'B' })
const report = events.findLast((event) => event.type === 'options-reported')!
@@ -174,3 +181,37 @@ it('never pairs an earlier unknown-model low run with the next prepared model',
await rig.dispose()
}
})
it('does not save a deferred model with a live unknown-model turn’s observed effort through handover', async () => {
// Same partial get_settings shape as the CLI capture: applied effort without model.
const claude = fakeClaude({
initModel: '',
settings: { applied: { effort: 'low' }, effective: { effortLevel: 'low' } }
})
const adapter = await acquired(claude)
const rig = await createQueuedMessageTestRig()
try {
rig.dispatch.mockImplementation((input) =>
adapter.dispatch({ ...input, sessionId: 'session-1', fence: 7 })
)
const first = rig.send('open the turn')
await first.result
await eventually(async () =>
expect((await rig.submission(first.id))?.handedOverAt).toBeDefined()
)
await rig.settleAccepted(first.id, 'first')
const followup = rig.send('keep working', undefined, { carried: { model: 'opus[1m]' } })
await followup.result
await eventually(() => expect(claude.connections[0]!.sent).toHaveLength(2))
await rig.host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION)
expect(await rig.dispatch.mock.results[1]!.value).toEqual({
state: 'admitted',
ranOptions: { effort: 'low' }
})
expect(claude.connections[0]!.calls.filter((call) => call.subtype === 'set_model')).toEqual([])
expect(rig.store.getRecord(SESSION)?.options).toBeUndefined()
} finally {
await adapter.closeAll()
await rig.dispose()
}
})
@@ -200,6 +200,10 @@ export function createClaudeJournalTranslator(
event.message.type === 'stream_event'
) {
turn.observeProviderCycleWork()
turn.inputChoiceWorkObserved ||=
event.message.type !== 'assistant' ||
event.message.is_api_error_message !== true ||
event.startedSend != null
}
}
}
@@ -276,6 +280,9 @@ export function createClaudeJournalTranslator(
get openTurnInLiveProviderCycle() {
return turn.openedInLiveProviderCycle
},
get openTurnHasInputWork() {
return turn.isOpen && turn.inputChoiceWorkObserved
},
flush,
childToolOwner: childQueries.childToolOwner,
childActivity: childQueries.childActivity,
@@ -11,8 +11,13 @@ import {
import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options'
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import * as dispatchContent from './claude-structured-dispatch-content'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
/** Claude ends a turn; a message sent before this joins the running turn on its choices. */
const claudeTurnEnds = (connection: Parameters<typeof claudeFrame>[0]) =>
claudeFrame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' })
const MODELS = [
{ value: 'default', resolvedModel: 'claude-sonnet-5' },
{
@@ -57,6 +62,7 @@ describe('Claude accepted message choices', () => {
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
claudeTurnEnds(connection)
await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
}
expect(
@@ -74,6 +80,7 @@ describe('Claude accepted message choices', () => {
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
claudeTurnEnds(connection)
}
expect(
connection.calls.filter((call) =>
@@ -204,6 +211,7 @@ describe('Claude accepted message choices', () => {
release()
await expect(a).resolves.toMatchObject({ state: 'admitted' })
expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt'])
claudeTurnEnds(connection)
order.length = 0
await expect(
@@ -329,6 +337,7 @@ describe('Claude accepted message choices', () => {
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
claudeTurnEnds(connection)
}
expect(captured.options.fastMode).toBe('true')
expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual(
@@ -371,6 +380,7 @@ describe('Claude accepted message choices', () => {
fence: 7
})
const connection = claude.connections[0]!
claudeTurnEnds(connection)
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' })
connection.calls.length = 0
await expect(
@@ -390,4 +400,103 @@ describe('Claude accepted message choices', () => {
])
expect(connection.sent).toHaveLength(2)
})
it('keeps a running turn on its choices and applies new ones at the next turn', async () => {
const claude = fakeClaude({
initModels: MODELS.map((model) => ({ ...model, supportsFastMode: true })),
routes: { list_models: () => MODELS }
})
const adapter = await acquired(claude)
const connection = claude.connections[0]!
const send = (clientMessageId: string, model: string, body = USER_MESSAGE) =>
adapter.dispatch({
sessionId: 'session-1',
clientMessageId,
body,
executionOptions: {
model,
effort: model === 'sonnet' ? 'high' : 'low',
fastMode: model === 'sonnet' ? 'false' : 'true'
},
fence: 7
})
const models = () =>
connection.calls.flatMap((call) => (call.subtype === 'set_model' ? [call.params?.model] : []))
await expect(send('opens', 'sonnet')).resolves.toMatchObject({ state: 'admitted' })
expect(models()).toEqual(['sonnet'])
const settings = () => connection.calls.filter((call) => call.subtype === 'apply_flag_settings')
const liveSettings = [...settings()]
// Typed while that turn runs, after the pick changed: it joins the turn as it is.
await expect(send('joins', 'opus')).resolves.toMatchObject({ state: 'admitted' })
expect(models()).toEqual(['sonnet'])
expect(settings()).toEqual(liveSettings)
claudeTurnEnds(connection)
const mail = {
...USER_MESSAGE,
from: {
kind: 'agent' as const,
senders: [],
orchestration: {
message: 'mail-notice' as const,
mailbox: 'agent:worker',
dispatchId: null,
messages: []
}
}
}
await expect(send('mail', 'opus', mail)).resolves.toMatchObject({ state: 'admitted' })
expect(models()).toEqual(['sonnet'])
expect(settings()).toEqual(liveSettings)
const held = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const original = dispatchContent.claudeDispatchMessageContent
const content = vi
.spyOn(dispatchContent, 'claudeDispatchMessageContent')
.mockImplementationOnce(async (body) => {
entered.resolve()
await held.promise
return original(body)
})
const delayed = send('next', 'opus')
try {
await entered.promise
claudeTurnEnds(connection)
held.resolve()
await expect(delayed).resolves.toMatchObject({ state: 'admitted' })
} finally {
held.resolve()
content.mockRestore()
}
expect(models()).toEqual(['sonnet', 'opus'])
expect(settings()).toContainEqual({
subtype: 'apply_flag_settings',
params: { settings: { effortLevel: 'low' } }
})
expect(settings()).toContainEqual({
subtype: 'apply_flag_settings',
params: { settings: { fastMode: true } }
})
expect(connection.sent).toHaveLength(4)
claudeTurnEnds(connection)
const settingsAfterTurn = [...settings()]
const originalSend = connection.send
connection.send = async (message, prepare) => {
await prepare?.()
connection.sent.push(message)
}
try {
await expect(send('starts', 'opus')).resolves.toMatchObject({ state: 'admitted' })
claudeFrame(connection, {
type: 'command_lifecycle',
state: 'started',
command_uuid: connection.sent.at(-1)?.uuid
})
await expect(send('before-echo', 'sonnet')).resolves.toMatchObject({ state: 'admitted' })
expect(models()).toEqual(['sonnet', 'opus'])
expect(settings()).toEqual(settingsAfterTurn)
} finally {
connection.send = originalSend
await adapter.closeAll()
}
})
})
@@ -1,3 +1,4 @@
import { claudeStartedUnechoedSend } from './claude-command-lifecycle'
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'
@@ -87,12 +88,20 @@ export async function dispatchClaudeMessage(
timeoutMs: number | undefined,
logger: StructuredAgentSessionLogger = defaultLogger
): ReturnType<StructuredAgentSessionAdapter['dispatch']> {
const messageOptions = input.executionOptions
let ranOptions: Record<string, string> = {}
const outcome = await dispatchClaudeTurn(session, input, async () => {
await input.beforeDispatch?.()
try {
input.signal?.throwIfAborted()
// Input joining a running turn, and Orca mail notices, run on the choices Claude already has;
// new choices apply at the next new turn.
const messageOptions =
session.translator?.openTurnHasInputWork ||
session.translator?.commandTurnId ||
claudeStartedUnechoedSend(session) ||
input.body.from?.orchestration?.message === 'mail-notice'
? undefined
: input.executionOptions
input.onInputPreparation?.()
if (messageOptions !== undefined) {
await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal)
@@ -34,6 +34,7 @@ describe('the context ring after a session option write', () => {
it('sizes a new session from the model it was launched with', () => {
const s = ringSession()
adoptClaudeStructuredSpawnOptions(s.session, {
sdkOptions: { model: 'opus[1m]' },
options: new Map([['model', 'opus[1m]']]),
fastModeAtStart: false
})
@@ -7,12 +7,35 @@ import {
readClaudeSettingsFastMode
} from './claude-structured-session-options'
import type { ClaudeSession } from './claude-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
import { beginClaudeOptionMutation } from './claude-structured-execution-observations'
const OPTION_ORDER = ['model', 'effort', 'fastMode'] as const
/** Approval mode is fixed at startup: a fallback mode is dropped only by reopening for a newly picked model.
* Compares with what Orca itself launched, so the reopened child never matches again. */
export function claudeModelTransitionRestart(
sessions: ReadonlyMap<string, ClaudeSession>
): NonNullable<StructuredAgentSessionAdapter['requiresModelTransitionRestart']> {
return (input) => {
const session = sessions.get(input.sessionId)
const model = input.executionOptions?.model
const opened = session?.firstRootInit?.permissionMode
const requested = session?.launchIntent?.permissionMode
return Boolean(
session?.fence === input.fence &&
model !== undefined &&
model !== session.launchIntent?.model &&
requested !== undefined &&
opened !== undefined &&
opened !== null &&
opened !== requested
)
}
}
export function restoredClaudeStructuredSessionOptions(
options: Readonly<Record<string, string>> | undefined
): Map<string, string> {
@@ -19,12 +19,7 @@ import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-opt
import { observeClaudeExecutionModel } from './claude-structured-execution-observations'
import { observeClaudeFastModeFacts } from './claude-structured-session-options'
import { createClaudeSessionPublication } from './claude-structured-session-publication'
import {
mintClaudeAcquisitionGeneration,
type ClaudeSession,
type ClaudeSessionAcquisitionInput,
type ClaudeLateDispatchOutcome
} from './claude-structured-session-state'
import * as sessionState from './claude-structured-session-state'
import { resolveClaudeAcquisitionError } from './claude-structured-session-close'
import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid'
@@ -35,6 +30,7 @@ import {
bindClaudeConnectionJournalControls,
createClaudeSessionJournalTranslator
} from './claude-structured-session-journal-control'
import { isRootClaudeFrame } from './claude-turn-opening'
export async function acquireClaudeSession({
input,
@@ -43,12 +39,13 @@ export async function acquireClaudeSession({
acquisitions,
exits,
callbacks
}: ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
}: sessionState.ClaudeSessionAcquisitionInput): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const prompts = new ClaudePromptRegistry()
const { previous, attempt } = acquisitions.start(sessionId, prompts)
let unbindReadingControl: (() => void) | undefined
let liveSession: ClaudeSession | null = null
let liveSession: sessionState.ClaudeSession | null = null
let firstRootInit: sessionState.ClaudeSession['firstRootInit']
let observedLeafUuid: string | null = null,
expectedProviderSessionId: string | null = null
// Preserve the CLI diagnostic when SDK cleanup reports a generic error.
@@ -70,7 +67,7 @@ export async function acquireClaudeSession({
)
const contextActivity = translator?.contextActivity ?? 0
const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps)
const observedOptions: ClaudeSession['reportedOptions'] = {}
const observedOptions: sessionState.ClaudeSession['reportedOptions'] = {}
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
@@ -80,6 +77,12 @@ export async function acquireClaudeSession({
}
return
}
if (message.type === 'system' && message.subtype === 'init' && isRootClaudeFrame(message)) {
firstRootInit ??= { permissionMode: readClaudeFrameString(message, 'permissionMode') }
if (liveSession) {
liveSession.firstRootInit = firstRootInit
}
}
const permissionChanged = observeClaudeReportedPermissionMode(observedOptions, message)
const modelChanged =
liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false
@@ -99,7 +102,7 @@ export async function acquireClaudeSession({
}
}
// Publish the echoed turn before settling its send to avoid transient idle status.
const settlements: ClaudeLateDispatchOutcome[] = []
const settlements: sessionState.ClaudeLateDispatchOutcome[] = []
const turnOrigin = liveSession
? resolveClaudeReplayTurn(liveSession, message, (settlement) => settlements.push(settlement))
: null
@@ -239,7 +242,7 @@ export async function acquireClaudeSession({
new Error(`claude stream-json for session ${sessionId} exited while being acquired`)
)
}
const publication = createClaudeSessionPublication({
const { session, acquisition } = createClaudeSessionPublication({
connection,
providerSessionId: launch.providerSessionId,
leafUuid: observedLeafUuid,
@@ -252,7 +255,7 @@ export async function acquireClaudeSession({
events: input.events,
...(unbindReadingControl ? { unbindReadingControl } : {}),
process,
acquisitionGeneration: mintClaudeAcquisitionGeneration(deps),
acquisitionGeneration: sessionState.mintClaudeAcquisitionGeneration(deps),
options: launch.savedOptions.options,
initialization: {
proof: initProof,
@@ -270,10 +273,13 @@ export async function acquireClaudeSession({
...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}),
observedAt: deps.now?.() ?? Date.now()
})
const session = publication.session
session.reportedOptions = observedOptions
liveSession = session
adoptClaudeStructuredSpawnOptions(session, launch.savedOptions)
session.firstRootInit = firstRootInit
adoptClaudeStructuredSpawnOptions(session, {
...launch.savedOptions,
sdkOptions: launch.options
})
acquisitions.deleteIfCurrent(sessionId, attempt)
await withAgentSessionCreatePhase('publish', input.recordPhase, async () => {
sessions.set(sessionId, session)
@@ -288,7 +294,7 @@ export async function acquireClaudeSession({
exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned')
)
}
return publication.acquisition
return acquisition
} catch (error) {
unbindReadingControl?.()
throw await resolveClaudeAcquisitionError({
@@ -10,7 +10,7 @@ import { claudeHeldSends, claudeHoldsDispatch } from './claude-command-lifecycle
import { releaseClaudeAcquisition } from './claude-structured-acquisition-release'
import { acquireClaudeSession } from './claude-structured-session-acquisition'
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
import { setClaudeStructuredSessionOption } from './claude-structured-options'
import * as claudeOptions from './claude-structured-options'
import { dispatchClaudeMessage } from './claude-structured-message-options'
import { readClaudeStructuredSessionOptions } from './claude-structured-session-options'
import {
@@ -190,6 +190,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
this.sessions.get(sessionId)?.commands.commands
readonly atRestCommands: ClaudeStructuredSessionAdapterDeps['atRestCommands']
holdsDispatch = (sessionId: string): boolean => claudeHoldsDispatch(this.sessions.get(sessionId))
requiresModelTransitionRestart = claudeOptions.claudeModelTransitionRestart(this.sessions)
liveTurnId = (sessionId: string): string | null | undefined =>
this.sessions.get(sessionId)?.translator?.currentTurnId
heldSends = (sessionId: string): ReadonlySet<string> | undefined => {
const session = this.sessions.get(sessionId) ?? this.exits.get(sessionId)?.session
const held = session && claudeHeldSends(session)
@@ -206,7 +209,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) =>
settleClaudePromptFreeingChild(this.asker(request), answerClaudeStructuredPrompt)
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
setClaudeStructuredSessionOption(
claudeOptions.setClaudeStructuredSessionOption(
this.session(input.sessionId),
input,
this.deps.requestTimeoutMs
@@ -4,10 +4,7 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f
import type { SubmissionRejectionFact } from '../../shared/agent-session-failure'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type {
StructuredAgentSessionAcquireInput,
StructuredAgentSessionOptionsReportedEvent
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type * as SessionAdapter from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
ClaudeStreamJsonConnection,
openClaudeStreamJsonConnection
@@ -63,7 +60,7 @@ export type ClaudeStructuredSessionEvent =
fence: number
}
| { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic }
| StructuredAgentSessionOptionsReportedEvent
| SessionAdapter.StructuredAgentSessionOptionsReportedEvent
| {
type: 'ended'
sessionId: string
@@ -185,6 +182,9 @@ export type ClaudeSession = {
appliedInputOptions: Map<string, string>
/** The model the child was launched with, as `--model`; null when it runs the CLI's own. */
launchedModel: string | null
/** What Orca put on this process's command line, and the first root init's answer; this process only. */
launchIntent?: { permissionMode?: string; model?: string }
firstRootInit?: { permissionMode: string | null }
/** A launched model the CLI answered `model_not_found` for; reported until a new pick. */
retiredModel?: string
/** Fast was omitted from launch settings and must be applied before message input. */
@@ -352,7 +352,7 @@ export async function cancelClaudeAcquisitionAttempt(
}
export type ClaudeSessionAcquisitionInput = {
input: StructuredAgentSessionAcquireInput
input: SessionAdapter.StructuredAgentSessionAcquireInput
deps: ClaudeStructuredSessionAdapterDeps
sessions: Map<string, ClaudeSession>
acquisitions: ClaudeAcquisitionRegistry
@@ -80,11 +80,20 @@ export function claudeStructuredSpawnOptions(input: {
export function adoptClaudeStructuredSpawnOptions(
session: Pick<
ClaudeSession,
'translator' | 'launchedModel' | 'fastModeAtStart' | 'appliedInputOptions'
'translator' | 'launchedModel' | 'launchIntent' | 'fastModeAtStart' | 'appliedInputOptions'
>,
spawn: Pick<ClaudeStructuredSpawnOptions, 'options' | 'fastModeAtStart'>
spawn: ClaudeStructuredSpawnOptions
): void {
session.launchedModel = spawn.options.get('model') ?? null
const { extraArgs, permissionMode } = spawn.sdkOptions
// A configured `--model` in the agent's arguments survives when the chat saved none.
const configuredModel = typeof extraArgs?.model === 'string' ? extraArgs.model : undefined
session.launchIntent = {
model: spawn.sdkOptions.model ?? configuredModel,
permissionMode: Object.hasOwn(extraArgs ?? {}, 'dangerously-skip-permissions')
? 'bypassPermissions'
: permissionMode
}
session.fastModeAtStart = spawn.fastModeAtStart
const fastMode = spawn.options.get('fastMode')
if (fastMode !== undefined && !spawn.fastModeAtStart) {
@@ -221,6 +221,28 @@ describe('Claude chat catch-up', () => {
}
)
it('never leaves a turn it imports running when its reply was already saved', async () => {
// An update killed the chat after Claude saved its reply but before the prompt's echo.
const c = await chat()
await c.live({ interrupted: true })
const saved = new Set(c.journal.snapshot().items.map((item) => item.itemId))
await c.write([
prompt('leaf', null, 'earlier request', 0),
prompt('unechoed', 'leaf', 'ask 1', 1),
reply('a1', 'unechoed', 'msg_a', { type: 'text', text: 'answer 1' }, 2)
])
expect(await c.open('leaf')).toBe(true)
const imported = c.journal
.snapshot()
.items.filter((item) => item.body.kind === 'turn' && !saved.has(item.itemId))
expect(imported).toHaveLength(1)
// It ends where it opened: in the thread, not inside the saved turn its reply matched.
expect(imported[0]).toMatchObject({
body: { state: 'completed' },
turnScope: { kind: 'thread' }
})
})
it('completes interrupted text and a result-only tool once at their original positions', async () => {
const c = await chat()
await c.live({
@@ -88,7 +88,8 @@ export function createClaudeTranscriptImportPlan(input: {
const identity = aliases.get(agentJournalItemKey(rawIdentity)) ?? rawIdentity
const key = agentJournalItemKey(identity)
const existing = saved(key)
if (projected.kind === 'turn' && (existing || historicalScope)) {
// A turn this import opened is history too: it must also take its end, never stay running.
if (projected.kind === 'turn' && (existing || (historicalScope && !mutations.has(key)))) {
return { accepted: true } as const
}
let body = projected
@@ -98,7 +99,7 @@ export function createClaudeTranscriptImportPlan(input: {
body = untimed
}
let turnScope =
projected.kind === 'message' && projected.role === 'user'
projected.kind === 'turn' || (projected.kind === 'message' && projected.role === 'user')
? options.turnScope
: (historicalScope ?? options.turnScope)
if (existing) {
@@ -87,6 +87,7 @@ function sessionHoldingTurn(turnId: string | null): ReturnType<typeof sessionFor
session.translator = {
handle: vi.fn(),
openTurnInLiveProviderCycle: false,
openTurnHasInputWork: false,
journalPrompts: {
resolve: vi.fn(),
handOver: () => () => {},
@@ -115,6 +115,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
holdsDispatch = (sessionId: string): boolean =>
this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false
liveTurnId = (sessionId: string): string | null | undefined =>
this.liveOwnerOrNull(sessionId)?.liveTurnId?.(sessionId)
requiresModelTransitionRestart: NonNullable<
StructuredAgentSessionAdapter['requiresModelTransitionRestart']
> = (input) =>
this.liveOwnerOrNull(input.sessionId)?.requiresModelTransitionRestart?.(input) ?? false
capturePendingOutput: StructuredAgentSessionAdapter['capturePendingOutput'] = (input) =>
this.liveOwnerOrNull(input.sessionId)?.capturePendingOutput?.(input)
@@ -241,6 +241,14 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
}): Promise<{ ok: true } | { ok: false; rejected: string }>
/** Derive unanswered retained work from the live child; false without one. */
holdsDispatch?(sessionId: string): boolean
/** The root turn the live child is running, null when idle; absent when the agent cannot say. */
liveTurnId?(sessionId: string): string | null | undefined
/** The live child opened in another approval mode than requested and this input picks another model. */
requiresModelTransitionRestart?(input: {
sessionId: string
fence: number
executionOptions?: AgentSessionExecutionOptions
}): boolean
/** Captures only this input's pending answer/output in the supplied live acquisition. */
capturePendingOutput?(input: {
sessionId: string
@@ -3,7 +3,7 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { afterEach, assert, beforeEach, expect, it, vi } from 'vitest'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { agentSessionSendSubmission } from '../../../shared/agent-session-wire'
@@ -34,6 +34,11 @@ import { recordingStructuredAgentSessionLogger } from './structured-agent-sessio
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
import * as unheld from './structured-agent-session-unheld-turns'
import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue'
const CALLER = { callerKey: 'client-1' }
@@ -45,19 +50,36 @@ let adapter: ClaudeStructuredSessionAdapter
let store: AgentSessionRecordStore
let claude: ReturnType<typeof fakeClaude>
beforeEach(async () => {
let startupGate: ReturnType<typeof Promise.withResolvers<void>>
const session = () => host.collaboratorsForTests().sessions.get(SESSION)!
const loop = () => host.collaboratorsForTests().conversationDelivery.loop
const ready = () =>
vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready'))
const init = (fields: Record<string, unknown>) =>
frame(claude.connections.at(-1)!, { type: 'system', subtype: 'init', ...fields })
const idle = () => init({ subtype: 'session_state_changed', state: 'idle' })
function echo() {
const connection = claude.connections.at(-1)!
frame(connection, { ...connection.sent.at(-1)!, isReplay: true, parent_tool_use_id: null })
}
beforeEach(async ({ task }) => {
const imported = task.name.includes('history items')
const selections = task.name.includes('fallback')
startupGate = Promise.withResolvers<void>()
root = await mkdtemp(join(tmpdir(), 'orca-claude-opening-send-'))
resetHostTestOperationIds()
const log = recordingStructuredAgentSessionLogger()
claude = fakeClaude({
replayUuid: null,
...(selections ? { initModel: 'claude-haiku-4-5' } : {}),
routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) }
})
const lifecycle: Promise<void>[] = []
adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
resolveLaunch: async ({ permissionMode }) => ({
pathToClaudeCodeExecutable: 'claude',
options: {},
options: { permissionMode: permissionMode === 'auto' ? 'auto' : 'default' },
cwd: root,
claudeConfigDir: join(root, 'claude-home'),
providerSessionId: PROVIDER_SESSION_ID,
@@ -87,18 +109,30 @@ beforeEach(async () => {
logger: log.logger,
now: () => NOW
})
if (imported) {
const open = adapter.open.bind(adapter)
vi.spyOn(adapter, 'open').mockImplementation(async (input) => {
await startupGate.promise
return open(input)
})
}
const params = hostTestAttachParams(null, {
provider: 'claude',
agent: 'claude',
...(selections ? { options: { permissionMode: 'auto', model: 'haiku' } } : {}),
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') },
providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }
})
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true })
await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready'))
await Promise.all(lifecycle)
if (!imported) {
await ready()
await Promise.all(lifecycle)
}
})
afterEach(async () => {
startupGate.resolve()
vi.restoreAllMocks()
await adapter.closeAll()
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
@@ -107,8 +141,12 @@ afterEach(async () => {
const eventually = <T>(assertion: () => T | Promise<T>) =>
vi.waitFor(assertion, { timeout: 10_000 })
async function send(text: string): Promise<string> {
const body = hostTestMessage(text)
async function send(
text: string,
options?: Record<string, string>,
from?: AgentJournalMessageItem['from']
): Promise<string> {
const body = { ...hostTestMessage(text), ...(from ? { from } : {}) }
const sent = await host.send(CALLER, {
envelope: {
sessionId: SESSION,
@@ -117,10 +155,11 @@ async function send(text: string): Promise<string> {
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: SESSION,
fields: { body }
fields: { body: hostTestMessage(text), ...(options ? { options } : {}) }
})
},
body
body,
...(options ? { options } : {})
})
if (!sent.ok) {
throw new Error(JSON.stringify(sent.refusal))
@@ -445,3 +484,162 @@ it('sends a message that joins an unproven Stop close to the resumed child, and
frame(resumed, { ...written(resumed, 'Carry on.')!, isReplay: true, parent_tool_use_id: null })
await eventually(() => expect(written(resumed, 'And this.')).toBeDefined())
})
async function sendChoice(
text: string,
options: Record<string, string>,
from?: AgentJournalMessageItem['from']
) {
await send(text, options, from)
await eventually(() => expect(written(claude.connections.at(-1)!, text)).toBeDefined())
echo()
await host.flushStreamedEvents(SESSION)
await eventually(() => expect(loop().isRunning(SESSION)).toBe(false))
}
it('preserves saved Auto through fallback, reopens once for a changed captured model, then reuses', async () => {
const first = claude.connections[0]!
init({ parent_tool_use_id: 'child', permissionMode: 'auto' })
init({ permissionMode: 'default', model: 'claude-haiku-4-5' })
init({ permissionMode: 'auto', model: 'claude-opus-5-5[1m]' })
await sendChoice('same fallback', { model: 'haiku' })
expect(claude.connections).toHaveLength(1)
const picked = { model: 'opus[1m]', effort: 'high', fastMode: 'true' }
await sendChoice('busy choice', picked)
expect(claude.connections).toHaveLength(1)
idle()
await sendChoice('mail notice', picked, {
kind: 'agent',
senders: [],
orchestration: {
message: 'mail-notice',
mailbox: 'agent:worker',
dispatchId: null,
messages: []
}
})
expect(claude.connections).toHaveLength(1)
idle()
await sendChoice('new choice', picked)
expect(first.closeCount).toBe(1)
const replacement = claude.connections[1]!
expect(replacement.launch.options).toMatchObject({
permissionMode: 'auto',
model: 'opus[1m]',
effort: 'high'
})
expect(replacement.launch.options.settings).toEqual({ fastMode: true })
idle()
await sendChoice('reuse choice', picked)
expect(claude.connections).toHaveLength(2)
expect(replacement.sent).toHaveLength(2)
expect(store.getRecord(SESSION)?.permissionMode).toBe('auto')
})
it('does not infer fallback from unknown init or a missing captured model', async () => {
init({ model: 'claude-haiku-4-5' })
init({ permissionMode: 'default' })
await sendChoice('unknown permission', { model: 'opus[1m]' })
expect(
adapter.requiresModelTransitionRestart({
sessionId: SESSION,
fence: session().child!.fence
})
).toBe(false)
expect(claude.connections).toHaveLength(1)
})
it.each([10, 100])(
'recovers imported running roots before readiness and drains waiting input once (%i history items)',
async (n) => {
const { journal, child } = session()
expect(adapter.liveTurnId(SESSION)).toBeNull()
const { fence, generation } = child!
assert(typeof generation === 'string')
const turn = (id: string, ownFence = fence, agentId?: string) =>
journal.appendItem(
{ provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: id },
{ kind: 'turn', turnId: id, state: 'running', startedAt: 1 },
{
fence,
ownerFence: ownFence,
turnScope: AGENT_JOURNAL_THREAD_SCOPE,
...(agentId ? { agentId } : {})
}
)
for (let i = 0; i < n; i++) {
await journal.appendItem(
{ provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: `history-${i}` },
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `Earlier reply ${i}` }]
},
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
const stale = await turn('imported-root')
const foreign = await turn('foreign-root', fence - 1)
const subagent = await turn('child-root', fence, 'child')
let visits = 0
let scanning = false
const settle = unheld.settleUnheldStructuredAgentSessionTurns
const scans = vi
.spyOn(unheld, 'settleUnheldStructuredAgentSessionTurns')
.mockImplementation(async (...args) => {
scanning = true
try {
await settle(...args)
} finally {
scanning = false
}
})
const visit = journal.visitItemsWithLinkage.bind(journal)
vi.spyOn(journal, 'visitItemsWithLinkage').mockImplementation((visitor) =>
visit((...args) => {
if (scanning) {
visits++
}
visitor(...args)
})
)
const batch = vi.spyOn(journal, 'appendPlannedLifecycleBatch')
expect(await send('after the update')).toBeTruthy()
expect(claude.connections[0]!.sent).toHaveLength(0)
const before = visits
startupGate.resolve()
await ready()
await vi.waitFor(() => expect(claude.connections[0]!.sent).toHaveLength(1))
echo()
await vi.waitFor(() => expect(journal.submissions()[0]?.dispatchState).toBe('accepted'))
await vi.waitFor(() => expect(loop().isRunning(SESSION)).toBe(false))
expect(journal.item(stale.itemId)?.body).toMatchObject({ state: 'interrupted' })
expect(journal.item(foreign.itemId)?.body).toMatchObject({ state: 'running' })
expect(journal.item(subagent.itemId)?.body).toMatchObject({ state: 'running' })
expect(batch).toHaveBeenCalledTimes(1)
expect(batch.mock.calls[0]![0].plan().mutations).toHaveLength(1)
// Count the actual startup scan, independently of pre-existing current-work reads.
expect(visits - before).toBe(n + 4)
const reconcile = (liveId: string | null | undefined) =>
unheld.settleUnheldStructuredAgentSessionTurns(journal, SESSION, fence, generation, liveId)
const scanned = visits
const cursor = journal.cursor()
await reconcile(undefined)
expect(visits).toBe(scanned)
const live = adapter.liveTurnId(SESSION)
expect(live).toBeTruthy()
await reconcile(live)
expect(journal.cursor()).toEqual(cursor)
expect(batch).toHaveBeenCalledTimes(1)
const scanCount = scans.mock.calls.length
const finalVisits = visits
idle()
await host.flushStreamedEvents(SESSION)
expect(scans).toHaveBeenCalledTimes(scanCount)
expect(visits).toBe(finalVisits)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(claude.connections[0]!.sent).toHaveLength(1)
expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()
expect(host.readStatusSummary(SESSION)?.status).not.toBe('working')
}
)
@@ -1,3 +1,4 @@
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import type { UnsentSendHold } from '../agent-session-journal/journal-unsent-send-hold'
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight'
@@ -19,7 +20,7 @@ import type {
} from './structured-agent-session-host-types'
export type StructuredAgentSessionDeliveryLoopDeps = {
startFlights?: Pick<StructuredAgentSessionStartFlights, 'has'>
startFlights?: Pick<StructuredAgentSessionStartFlights, 'get'>
deliveryOrder: StructuredAgentSessionDeliveryOrder
sessions: ReadonlyMap<string, StructuredAgentSessionHostSession>
adapter: StructuredAgentSessionAdapter
@@ -29,7 +30,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = {
trackStart: <T>(start: Promise<T>) => Promise<T>
startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim
/** Retire mismatched login/permission children under serialization. */
retireOutgrownChild: (sessionId: string, continuingId?: string) => Promise<void>
retireOutgrownChild: (
sessionId: string,
continuingId?: string,
next?: AgentJournalSubmission
) => Promise<void>
immediatePermission?: (sessionId: string) => AgentChatPermissionChoice | null
retainedWork?: (sessionId: string, continuingId: string) => boolean
readiness: (
@@ -1,6 +1,18 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure'
import {
acceptedDispatch,
createRestTestRig,
foundRestTestChat,
REST_TEST_CALLER as CALLER,
REST_TEST_SESSION as SESSION,
restTestSend,
type RestTestRig
} from './structured-agent-session-rest-test-rig'
import * as handover from './structured-agent-session-handover'
import type { StructuredAgentSessionStartFlight } from './structured-agent-session-start-flight'
import type {
StructuredAgentSessionChildEndCause,
StructuredAgentSessionEndedChild
@@ -63,3 +75,196 @@ describe('what a child end means for the messages queued behind it', () => {
}
)
})
describe('delivery iteration settlement and ownership', () => {
let rig: RestTestRig
const loop = () => rig.host.collaboratorsForTests().conversationDelivery.loop
const journal = () => rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
const fence = () => rig.store.getRecord(SESSION)!.lease.runtimeFence
const parked = () => vi.waitFor(() => expect(loop().isRunning(SESSION)).toBe(false))
const timer = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
beforeEach(async () => {
rig = await createRestTestRig()
await foundRestTestChat(rig)
await parked()
})
afterEach(async () => {
vi.restoreAllMocks()
loop().dispose()
await rig.dispose()
})
function noWrite() {
// Old rollback-shaped boundary: no provider admission and no journal commit.
// Bound a broken loop before it can starve timers on this shared machine.
let attempts = 0
return vi.spyOn(handover, 'beginSubmissionHandover').mockImplementation(async () => {
if (attempts++ > 8) {
throw new Error('iteration bound')
}
return { run: async (commit) => commit(async () => {}) }
})
}
async function pending() {
expect(await rig.host.send(CALLER, restTestSend('still owed', fence()))).toMatchObject({
ok: true
})
await parked()
return journal().submissions().at(-1)!
}
it('parks a no-op dispatch/resolver, preserves unwritten input, then delivers once on a legitimate wake', async () => {
const blocked = noWrite()
const row = await pending()
expect(blocked.mock.calls.length).toBeLessThanOrEqual(2)
expect(row).toMatchObject({ dispatchState: 'pending', handoverRecorded: true })
expect(row.handedOverAt).toBeUndefined()
await timer()
expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1)
blocked.mockRestore()
const current = rig.host.collaboratorsForTests().sessions.get(SESSION)!
current.child!.phase = 'starting'
const ready = Promise.withResolvers<'ready'>()
const readiness = vi
.spyOn(loop()['deps'], 'readiness')
.mockReturnValue({ wait: () => ready.promise })
const draining = loop().wake(SESSION)
await vi.waitFor(() => expect(readiness).toHaveBeenCalledTimes(1))
const replacement = { ...current.child!, generation: 'replacement', phase: 'ready' as const }
current.child = replacement
current.lastEndedChild = ended('user-stop', true)
rig.adapter.dispatch.mockImplementationOnce(async () => {
expect(current.child).toBe(replacement)
return acceptedDispatch()
})
ready.resolve('ready')
await draining
expect(journal().submissions().at(-1)?.dispatchState).toBe('accepted')
expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)
await loop().wake(SESSION)
expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)
})
it.each(['unchanged', 'completed', 'replacement', 'immediate'] as const)(
'consumes only completion of the flight actually joined (%s)',
async (schedule) => {
const blocked = noWrite()
const flights = rig.host.collaboratorsForTests().runtimeState.startFlights
const done = Promise.withResolvers<{ ok: true }>()
const flight: StructuredAgentSessionStartFlight = {
attemptId: 'F',
outcome: done.promise,
abort() {}
}
flights.register(SESSION, flight)
const start = vi.spyOn(loop()['deps'], 'startProviderChild')
if (schedule === 'immediate') {
start.mockImplementation(() => ({ outcome: Promise.resolve({ ok: true }) }))
}
expect(await rig.host.send(CALLER, restTestSend('join F', fence()))).toMatchObject({
ok: true
})
await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(1))
if (schedule === 'completed') {
flights.end(SESSION, 'F')
}
const next = Promise.withResolvers<{ ok: true }>()
if (schedule === 'replacement') {
flights.register(SESSION, { ...flight, attemptId: 'G', outcome: next.promise })
}
done.resolve({ ok: true })
if (schedule === 'replacement') {
await vi.waitFor(() => expect(start).toHaveBeenCalledTimes(2))
expect(loop().isRunning(SESSION)).toBe(true)
next.resolve({ ok: true })
}
await parked()
await timer()
expect(start).toHaveBeenCalledTimes(schedule === 'replacement' ? 2 : 1)
expect(blocked).toHaveBeenCalledTimes(schedule === 'completed' ? 1 : 0)
expect(journal().submissions().at(-1)?.handedOverAt).toBeUndefined()
}
)
it('covers dispatch journal settlement, resolver, release and a wake during final settlement together', async () => {
const held = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const boundary = vi
.spyOn(handover, 'beginSubmissionHandover')
.mockImplementation(async (ctx) => ({
run: async (commit) => {
try {
// Dispatch itself writes, before the serialized resolver exists.
await ctx.journal.appendItem(
{ provider: 'codex', threadId: 'thread', turnId: 'settled', ordinal: 0 },
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'settled' }] },
{ fence: ctx.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
entered.resolve()
await held.promise
await commit(async () => {
void loop().wake(SESSION)
})
} finally {
boundary.mockImplementation(async () => {
if (boundary.mock.calls.length > 8) {
throw new Error('iteration bound')
}
return { run: async (commit) => commit(async () => {}) }
})
}
}
}))
const sending = rig.host.send(CALLER, restTestSend('settlement wake', fence()))
await entered.promise
expect(await sending).toMatchObject({ ok: true })
held.resolve()
await parked()
expect(boundary).toHaveBeenCalledTimes(2)
expect(journal().submissions().at(-1)?.handedOverAt).toBeUndefined()
// A wake at serialized parking creates a successor before the old finalizer runs.
const deps = loop()['deps']
const park = deps.deliveryOrder.parkSends.bind(deps.deliveryOrder)
const successorHeld = Promise.withResolvers<void>()
const successorEntered = Promise.withResolvers<void>()
let successor: Promise<void> | undefined
vi.spyOn(deps.deliveryOrder, 'parkSends').mockImplementationOnce((id) => {
park(id)
boundary.mockImplementationOnce(async () => ({
run: async (commit) => {
successorEntered.resolve()
await successorHeld.promise
await commit(async () => {})
}
}))
successor = loop().wake(id)
})
await loop().wake(SESSION)
await successorEntered.promise
expect(loop().isRunning(SESSION)).toBe(true)
loop().dispose()
expect(loop()['running'].size).toBe(0)
successorHeld.resolve()
await successor
expect(loop()['runs'].size).toBe(0)
expect(journal().submissions().at(-1)?.handedOverAt).toBeUndefined()
await loop().wake(SESSION)
expect(loop().isRunning(SESSION)).toBe(false)
})
it('reclaims N and 10N parked runs with linear boundary fact reads', async () => {
const sessions = rig.host.collaboratorsForTests().sessions
const cursor = vi.spyOn(journal(), 'cursor')
for (const n of [10, 100]) {
cursor.mockClear()
for (let i = 0; i < n; i++) {
const id = `scale-${i}`
sessions.set(id, { ...sessions.get(SESSION)! })
await loop().wake(id)
sessions.delete(id)
}
expect(cursor).toHaveBeenCalledTimes(2 * n)
expect(loop()['running'].size).toBe(0)
expect(loop()['runs'].size).toBe(0)
}
})
})
@@ -21,6 +21,7 @@ import { holdRestartedStructuredAgentSessionSends } from './structured-agent-ses
import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure'
import { waitsForCodexTurnOptions } from './structured-agent-session-message-options'
import { sameProviderChild } from './structured-agent-session-provider-child'
import { DeliveryProgress } from './structured-agent-session-delivery-progress'
export type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps'
@@ -39,7 +40,7 @@ type Prepared =
type StartFailure = NonNullable<ReturnType<typeof startThatFailedWhileQueued>>
export class StructuredAgentSessionDeliveryLoop {
private readonly running = new Set<string>()
private readonly running = new Map<string, DeliveryProgress>()
private readonly runs = new Map<string, Promise<void>>()
private disposed = false
@@ -51,6 +52,7 @@ export class StructuredAgentSessionDeliveryLoop {
dispose(): void {
this.disposed = true
this.running.clear()
this.deps.deliveryOrder.dispose()
}
@@ -59,6 +61,7 @@ export class StructuredAgentSessionDeliveryLoop {
if (this.disposed) {
return Promise.resolve()
}
this.running.get(sessionId)?.woke()
this.deps.deliveryOrder.reconcile(
sessionId,
this.deps.sessions.get(sessionId)?.journal.submissions() ?? []
@@ -66,22 +69,29 @@ export class StructuredAgentSessionDeliveryLoop {
if (this.running.has(sessionId)) {
return this.runs.get(sessionId) ?? Promise.resolve()
}
this.running.add(sessionId)
const run = this.run(sessionId).finally(() => {
const progress = new DeliveryProgress()
this.running.set(sessionId, progress)
const run = this.run(sessionId, progress).finally(() => {
if (this.runs.get(sessionId) === run) {
this.runs.delete(sessionId)
if (this.running.get(sessionId) === progress) {
this.stop(sessionId)
}
}
})
this.runs.set(sessionId, run)
return run
}
private async run(sessionId: string): Promise<void> {
private async run(sessionId: string, progress: DeliveryProgress): Promise<void> {
// Only this run's current owner may act; a stopped or replaced run reads 'stop'.
const owned = <T>(task: () => Promise<T>) =>
this.deps.serialize(sessionId, async () =>
this.running.get(sessionId) === progress ? task() : ('stop' as const)
)
try {
for (;;) {
const prepared = await this.deps.trackStart(
this.deps.serialize(sessionId, () => this.prepare(sessionId))
)
const prepared = await this.deps.trackStart(owned(() => this.prepare(sessionId, progress)))
if (prepared === 'stop') {
return
}
@@ -102,15 +112,13 @@ export class StructuredAgentSessionDeliveryLoop {
}
)
if (!completed) {
await this.deps
.serialize(sessionId, async () => this.stop(sessionId))
.catch((error: unknown) =>
this.deps.logger.warn('parking delivery after a failed start failed', {
scope: 'delivery-loop-start',
sessionId,
error
})
)
await owned(async () => this.stop(sessionId)).catch((error: unknown) =>
this.deps.logger.warn('parking delivery after a failed start failed', {
scope: 'delivery-loop-start',
sessionId,
error
})
)
return
}
// The flight settled its requester; joiners rederive their own work.
@@ -120,7 +128,7 @@ export class StructuredAgentSessionDeliveryLoop {
await prepared.readiness.wait()
}
await prepared.before
const handed = await this.deps.serialize(sessionId, () =>
const handed = await owned(() =>
this.handOver(
sessionId,
prepared.awaited,
@@ -145,30 +153,29 @@ export class StructuredAgentSessionDeliveryLoop {
error
})
const cause = { hostFault: true } as const
await this.deps
.serialize(sessionId, async () => {
try {
const session = this.deps.sessions.get(sessionId)
const selected = session && oldestQueuedSubmission(session)
await this.fail(sessionId, { startKey: null, cause }, selected?.clientMessageId ?? null)
} finally {
this.stop(sessionId)
}
})
.catch((failure: unknown) => {
// Keep failed-settlement leftovers for the next accept/open recovery.
this.deps.logger.warn('recording a failed delivery failed', {
scope: 'delivery-loop-fail',
sessionId,
error: failure
})
await owned(async () => {
try {
const session = this.deps.sessions.get(sessionId)
const selected = session && oldestQueuedSubmission(session)
await this.fail(sessionId, { startKey: null, cause }, selected?.clientMessageId ?? null)
} finally {
this.stop(sessionId)
}
}).catch((failure: unknown) => {
// Keep failed-settlement leftovers for the next accept/open recovery.
this.deps.logger.warn('recording a failed delivery failed', {
scope: 'delivery-loop-fail',
sessionId,
error: failure
})
})
}
}
private async prepare(sessionId: string): Promise<Prepared> {
private async prepare(sessionId: string, progress: DeliveryProgress): Promise<Prepared> {
const session = this.deps.sessions.get(sessionId)
if (!session || this.disposed) {
// A step that proved no change since the last one would repeat forever; park until a commit or wake.
if (!session || this.disposed || progress.decide(sessionId, this.deps).kind === 'park') {
return this.stop(sessionId)
}
// Retry failed open recovery before delivery; never hand over earlier-process sends.
@@ -187,19 +194,17 @@ export class StructuredAgentSessionDeliveryLoop {
if (!oldest || (session.child && work && structuredAgentSessionCommandRunning(work))) {
return this.stop(sessionId)
}
if (this.deps.startFlights?.has(sessionId)) {
return { start: this.deps.startProviderChild(sessionId, oldest.clientMessageId) }
if (this.deps.startFlights?.get(sessionId)) {
return { start: progress.claim(sessionId, this.deps, oldest.clientMessageId) }
}
const failedStart = startThatFailedWhileQueued(session, oldest)
if (failedStart) {
return this.fail(sessionId, failedStart, oldest.clientMessageId)
}
await this.deps.retireOutgrownChild(sessionId)
await this.deps.retireOutgrownChild(sessionId, undefined, oldest)
const child = this.deps.sessions.get(sessionId)?.child
if (!child || child.close) {
return {
start: this.deps.startProviderChild(sessionId, oldest.clientMessageId)
}
return { start: progress.claim(sessionId, this.deps, oldest.clientMessageId) }
}
return {
ok: true,
@@ -253,7 +258,7 @@ export class StructuredAgentSessionDeliveryLoop {
}
if (waited) {
// Verify awaited-child identity before rechecking launch permissions.
await this.deps.retireOutgrownChild(sessionId)
await this.deps.retireOutgrownChild(sessionId, undefined, next)
if (session.child !== awaitedChild) {
return 'continue'
}
@@ -0,0 +1,63 @@
import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop-deps'
import type { StructuredAgentSessionStartFlight } from './structured-agent-session-start-flight'
function sample(sessionId: string, reads: StructuredAgentSessionDeliveryLoopDeps, wakes: number) {
const session = reads.sessions.get(sessionId)
const child = session?.child
return {
journal: session?.journal.cursor(),
child: [child?.generation, child?.fence, child?.phase, child?.close !== undefined] as const,
wakes
}
}
function transition<T>(witness: T) {
return { kind: 'transition', witness } as const
}
/** Owned by one active run; each serialized decision consumes every preceding iteration fact. */
export class DeliveryProgress {
private wakes = 0
private before?: ReturnType<typeof sample>
// The loop decides again only after awaiting this joined claim, so it has settled by then.
private joined?: StructuredAgentSessionStartFlight
woke(): void {
this.wakes += 1
}
/** Read any existing flight before this step can register its own; only a joined claim proves startup. */
claim(sessionId: string, reads: StructuredAgentSessionDeliveryLoopDeps, startedFor: string) {
const flight = reads.startFlights?.get(sessionId)
const claim = reads.startProviderChild(sessionId, startedFor)
this.joined = flight?.outcome === claim.outcome ? flight : undefined
return claim
}
decide(sessionId: string, reads: StructuredAgentSessionDeliveryLoopDeps) {
const now = sample(sessionId, reads, this.wakes)
const before = this.before
const joined = this.joined
this.before = now
this.joined = undefined
if (!before) {
return { kind: 'initial' } as const
}
if (
before.journal?.epoch !== now.journal?.epoch ||
before.journal?.sequence !== now.journal?.sequence
) {
return transition({ kind: 'journal', before, now } as const)
}
if (before.child.some((value, index) => value !== now.child[index])) {
return transition({ kind: 'child', before, now } as const)
}
if (joined && reads.startFlights?.get(sessionId) !== joined) {
return transition({ kind: 'startup', flight: joined } as const)
}
if (before.wakes !== now.wakes) {
return transition({ kind: 'wake', before: before.wakes, after: now.wakes } as const)
}
return { kind: 'park' } as const
}
}
@@ -104,12 +104,20 @@ export function createStructuredAgentSessionConversationDelivery(input: {
serialize: input.serialize,
trackStart: input.trackStart,
startProviderChild: input.startProviderChild,
retireOutgrownChild: async (sessionId, continuingId) => {
retireOutgrownChild: async (sessionId, continuingId, next) => {
// Defer retirement while personal Stop holds handover; its next step decides.
const session = input.clientDelivery.readStopping(sessionId)
? undefined
: sessions.get(sessionId)
await retireOutgrownStructuredAgentSessionChild(sessionId, session, {
next,
requiresModelTransitionRestart: (submission) =>
session?.child?.phase === 'ready' &&
deps.adapter.requiresModelTransitionRestart?.({
sessionId,
fence: session.child.fence,
executionOptions: submission.options
}) === true,
work: retainedWork(sessionId, continuingId),
launchPermission: async () => {
const record = deps.store.getRecord(sessionId)
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire'
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
@@ -10,101 +9,22 @@ function record(provider: string, saved: Record<string, string> | undefined): Ag
return { sessionId: 'session-1', provider, options, permissionMode } as AgentSessionRecord
}
const listing = (
models: { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[]
): AgentSessionModelCatalogResult => ({
origin: 'probe',
fetchedAt: 1,
models: models.map((model) => ({
label: model.id,
efforts: [],
isDefault: false,
...model
}))
})
function launchPermission(
provider: string,
options: Record<string, string> | undefined,
answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' }
) {
const read = vi.fn(async () => {
if (answer instanceof Error) {
throw answer
}
return answer
})
const modelCatalog = {
peek: () => null,
read,
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
providerStarted: vi.fn()
}
return {
read,
result: structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared(), modelCatalog },
record(provider, options)
)
}
function launchPermission(provider: string, options?: Record<string, string>) {
return structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared() },
record(provider, options)
)
}
describe('the permission mode a chat starts with', () => {
it('launches a saved mode that needs no model as it is, without reading the catalog', async () => {
const claude = launchPermission('claude', { permissionMode: 'accept-edits' })
await expect(claude.result).resolves.toBe('accept-edits')
expect(claude.read).not.toHaveBeenCalled()
const codex = launchPermission('codex', { permissionMode: 'auto' })
await expect(codex.result).resolves.toBe('auto')
expect(codex.read).not.toHaveBeenCalled()
})
it('asks for a chat saved before modes existed', async () => {
await expect(launchPermission('claude', undefined).result).resolves.toBe('ask')
})
it('runs Claude auto on a model the saved catalog lists as supporting it', async () => {
const supported = launchPermission(
'claude',
{ permissionMode: 'auto', model: 'opus' },
listing([{ id: 'opus', supportsAutoMode: true }])
)
await expect(supported.result).resolves.toBe('auto')
expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true }))
})
it('matches a chat with no model to the listed default', async () => {
await expect(
launchPermission(
'claude',
{ permissionMode: 'auto' },
listing([
{ id: 'sonnet', isDefault: true, supportsAutoMode: false },
{ id: 'opus', supportsAutoMode: true }
])
).result
).resolves.toBe('ask')
})
it('asks when the catalog cannot confirm the model runs auto', async () => {
for (const answer of [
{ origin: 'unknown' } as const,
listing([{ id: 'opus' }]),
listing([{ id: 'sonnet', supportsAutoMode: true }]),
new Error('catalog unavailable')
]) {
await expect(
launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result
).resolves.toBe('ask')
it('delegates saved own settings', async () => {
for (const provider of ['claude', 'codex']) {
await expect(launchPermission(provider, { permissionMode: 'own' })).resolves.toBe('own')
}
})
it('has none for an agent with no modes', async () => {
await expect(launchPermission('pi', { permissionMode: 'full-access' }).result).resolves.toBe(
null
)
it('has none for an unregistered agent or one with no modes', async () => {
await expect(launchPermission('grok', { permissionMode: 'full-access' })).resolves.toBe(null)
await expect(launchPermission('pi', { permissionMode: 'full-access' })).resolves.toBe(null)
})
})
@@ -5,48 +5,20 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
agentChatPermissionModes,
savedChatPermissionMode,
chatModelSupportsAutoMode,
chatPermissionModeNeedsModelSupport,
effectiveChatPermissionMode,
type AgentChatPermissionChoice
} from '../../../shared/agent-chat-permission-mode'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
/** Use only the saved, folder-scoped catalog so startup never waits on a listing. */
async function listedAutoModeSupport(
catalog: StructuredAgentSessionHostDeps['modelCatalog'],
record: Pick<AgentSessionRecord, 'sessionId' | 'provider' | 'options' | 'permissionMode'>
): Promise<boolean> {
const listing = await catalog
?.read({
agent: record.provider,
sessionId: record.sessionId,
savedOnly: true,
inSessionWorkspace: true
})
.catch(() => null)
return (
listing !== null &&
listing !== undefined &&
listing.origin !== 'unknown' &&
chatModelSupportsAutoMode(listing.models, record.options?.model)
)
}
/** null for unregistered agents or agents without permission modes. */
export async function structuredAgentSessionLaunchPermission(
deps: Pick<StructuredAgentSessionHostDeps, 'agents' | 'modelCatalog'>,
deps: Pick<StructuredAgentSessionHostDeps, 'agents'>,
record: Pick<AgentSessionRecord, 'sessionId' | 'provider' | 'options' | 'permissionMode'>
): Promise<AgentChatPermissionChoice | null> {
const support = agentChatPermissionModes(record.provider)
if (!support || !deps.agents.definition(record.provider)) {
return null
}
const intent = record.permissionMode
const autoSupported =
chatPermissionModeNeedsModelSupport(support, intent) &&
(await listedAutoModeSupport(deps.modelCatalog, record))
return effectiveChatPermissionMode(support, intent, autoSupported)
return savedChatPermissionMode(support, record.permissionMode)
}
/** A synchronous admission read for providers whose modes need no model-catalog evidence. */
@@ -14,10 +14,7 @@ import {
retireOutgrownStructuredAgentSessionChild,
structuredAgentSessionChildReportedSignedOut
} from './structured-agent-session-outgrown-child'
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
import type {
AgentJournalItemBody,
@@ -128,6 +125,7 @@ const conversation = (
visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {})
),
itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence,
itemBody: (itemId: string) => items.find((item) => item.itemId === itemId)?.body ?? null,
newestTurn: (): AgentJournalTurnLifecycle | null =>
input.newestTurnEndedAt === undefined
? null
@@ -293,87 +291,6 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
).not.toHaveBeenCalled()
})
describe('against the mode a start launches with', () => {
// Model Claude’s saved catalog: resolved default alias, auto on every model except haiku.
const listed = [
{ id: 'opus[1m]', isDefault: true, supportsAutoMode: true },
{ id: 'sonnet', isDefault: false, supportsAutoMode: true },
{ id: 'haiku', isDefault: false }
]
const catalog = (rows: typeof listed) => ({
peek: () => null,
read: vi.fn(async () => ({
origin: 'probe' as const,
fetchedAt: 1,
models: rows.map((row) => ({ ...row, label: row.id, efforts: [] }))
})),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
providerStarted: vi.fn()
})
const launch = ({ permissionMode, ...options }: Record<string, string>, rows = listed) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent, options and mode.
const record = {
sessionId: 'session',
provider: 'claude',
options,
permissionMode
} as AgentSessionRecord
return () =>
structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared(), modelCatalog: catalog(rows) },
record
)
}
const stoppedAfterLaunch = async (
launched: AgentChatPermissionChoice,
current: () => Promise<AgentChatPermissionChoice | null>
) =>
(await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current))
.stopAgent.mock.calls.length > 0
it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => {
const unconfirmed = [
launch({ model: 'haiku', permissionMode: 'auto' }),
launch({ permissionMode: 'auto' }, [{ id: 'opus[1m]', isDefault: true }])
]
for (const autoPick of unconfirmed) {
await expect(autoPick()).resolves.toBe('ask')
expect(await stoppedAfterLaunch('full-access', autoPick)).toBe(true)
expect(await stoppedAfterLaunch('accept-edits', autoPick)).toBe(true)
expect(await stoppedAfterLaunch('ask', autoPick)).toBe(false)
}
})
it('never restarts a child it just started, from an alias or a resolved id', async () => {
const records: Record<string, string>[] = [
{ permissionMode: 'auto' },
{ model: 'default', permissionMode: 'auto' },
{ model: 'opus[1m]', permissionMode: 'auto' },
{ model: 'sonnet', permissionMode: 'auto' },
{ model: 'haiku', permissionMode: 'auto' },
{ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' }
]
const runsAuto = [true, true, true, true, false, false]
for (const [index, options] of records.entries()) {
const current = launch(options)
const launched = await current()
expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask')
expect(await stoppedAfterLaunch(launched!, current)).toBe(false)
}
})
// A resolved ID initially launches Ask; the reported catalog alias enables auto after one restart.
it('settles after one restart once a resolved id is reported as its alias', async () => {
const first = await launch({ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' })()
const reported = launch({ model: 'opus[1m]', permissionMode: 'auto' })
expect(await stoppedAfterLaunch(first!, reported)).toBe(true)
expect(await stoppedAfterLaunch((await reported())!, reported)).toBe(false)
})
})
it('never reads the mode for a child launched with none', async () => {
const current = now('full-access')
expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled()
@@ -12,9 +12,13 @@ import {
} from './structured-agent-session-idle-sweep'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
type OutgrownChildReading = Pick<StructuredAgentSessionHostSession, 'child'> & {
journal: Pick<AgentSessionJournal, 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn'> & {
journal: Pick<
AgentSessionJournal,
'visitItemsWithLinkage' | 'itemFence' | 'newestTurn' | 'itemBody'
> & {
submissions(): readonly Pick<AgentJournalSubmission, 'dispatchState' | 'fence' | 'rejection'>[]
}
}
@@ -81,6 +85,23 @@ async function launchedWithOtherPermissionMode(
)
}
/** Orca mail notices are not the user's turn: they keep the running process and its choices. */
function picksNewLaunchModel(
session: OutgrownChildReading,
next: AgentJournalSubmission | undefined,
requiresModelTransitionRestart: ((next: AgentJournalSubmission) => boolean) | undefined
): boolean {
if (!next || !requiresModelTransitionRestart) {
return false
}
const body = session.journal.itemBody(agentJournalSubmissionKey(next.clientMessageId))
return (
body?.kind === 'message' &&
body.from?.orchestration?.message !== 'mail-notice' &&
requiresModelTransitionRestart(next)
)
}
/** Retire under serialization only when no work is owed; failed Stop leaves a close for startup to join. */
export async function retireOutgrownStructuredAgentSessionChild(
sessionId: string,
@@ -91,6 +112,9 @@ export async function retireOutgrownStructuredAgentSessionChild(
childWork: () => readonly AgentChildWorkView[] | undefined
}
launchPermission?: () => Promise<AgentChatPermissionChoice | null>
/** The send about to be handed over; a new model pick may need the requested approval mode. */
next?: AgentJournalSubmission
requiresModelTransitionRestart?: (next: AgentJournalSubmission) => boolean
permissionRequiresAcquisition?: (mode: AgentChatPermissionChoice) => boolean
stopAgent: (sessionId: string) => Promise<void>
logger: StructuredAgentSessionLogger
@@ -100,6 +124,7 @@ export async function retireOutgrownStructuredAgentSessionChild(
!session ||
!(
structuredAgentSessionChildReportedSignedOut(session) ||
picksNewLaunchModel(session, deps.next, deps.requiresModelTransitionRestart) ||
(await launchedWithOtherPermissionMode(
session.child,
deps.launchPermission,
@@ -22,6 +22,7 @@ import {
import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts'
import { recordAgentSessionProviderHandle } from '../../runtime/agent-session/ownership/agent-session-provider-handle-transition'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { settleUnheldStructuredAgentSessionTurns } from './structured-agent-session-unheld-turns'
import {
applyStructuredRewindRecovery,
readStructuredRewindRecovery,
@@ -38,6 +39,8 @@ export type StructuredAgentSessionProviderStartedContext = {
startupAttempts: Pick<StructuredAgentSessionStartupAttempts, 'ready' | 'onClock' | 'readiness'>
acquireAborts: Pick<StructuredAgentSessionAcquireAborts, 'begin'>
}
/** Commits the child's buffered lifecycle events. */
flushLifecycle?: (sessionId: string) => Promise<unknown>
/** Release startup’s delivery barrier and drain all currently handable sends. */
wakeDelivery: (sessionId: string) => Promise<void>
stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void
@@ -56,6 +59,8 @@ export async function settleStructuredAgentSessionProviderStarted(
): Promise<void> {
// Read provider state outside serialization so sends can still be accepted.
const rewind = await readRewindAtStart(context, event, signal)
// Restored history is committed and the child's own events drained before its turns are judged.
await context.flushLifecycle?.(event.sessionId)
// Follow attach publication so its proven lease has committed.
await context.serialize(event.sessionId, async () => {
signal?.throwIfAborted()
@@ -84,6 +89,19 @@ export async function settleStructuredAgentSessionProviderStarted(
) {
return null
}
await settleUnheldStructuredAgentSessionTurns(
session.journal,
event.sessionId,
child.fence,
event.acquisitionGeneration,
context.deps.adapter.liveTurnId?.(event.sessionId)
).catch((error: unknown) =>
context.deps.logger.warn('settling turns the agent is not running failed', {
scope: 'unheld-turns',
sessionId: event.sessionId,
error
})
)
context.runtimeState.startupAttempts.ready(event.sessionId, child)
noteStructuredAgentSessionRuntimePermission(session.child, event.reportedOptions, true)
context.publishStatus?.(event.sessionId)
@@ -146,7 +146,7 @@ export async function recordStructuredAgentSessionRanOptions(
const reported = childRuns.get(child)?.reported
// A report only completes an agent's own evidence of the dispatch, never stands in for it.
const newerModel = checkpoint && reported !== checkpoint.reported ? reported?.model : undefined
const model = executed ? (executed.model ?? message?.model ?? newerModel) : message?.model
const model = executed ? (executed.model ?? newerModel) : message?.model
childRuns.set(child, { ...childRuns.get(child), owed: executed && !model ? handed : undefined })
if (!model) {
return
@@ -0,0 +1,50 @@
// A turn row reading running under the live child that the child itself is not running is over.
// Earlier children's rows are swept before attach; this covers rows that reach the live fence
// without the child's own lifecycle, such as restored history.
import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict'
/** `liveTurnId` undefined means the agent cannot say, so nothing is settled. */
export async function settleUnheldStructuredAgentSessionTurns(
journal: Pick<
AgentSessionJournal,
'visitItemsWithLinkage' | 'itemFence' | 'item' | 'appendPlannedLifecycleBatch'
>,
sessionId: string,
fence: number,
generation: string,
liveTurnId: string | null | undefined
): Promise<void> {
if (liveTurnId === undefined) {
return
}
const unheld: AgentJournalRenderItem[] = []
journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => {
const item =
body.kind === 'turn' &&
body.state === 'running' &&
body.turnId !== liveTurnId &&
isRootAgentJournalItem(attribution) &&
journal.itemFence(itemId) === fence
? journal.item(itemId)
: null
if (item) {
unheld.push(item)
}
})
if (unheld.length === 0) {
return
}
await journal.appendPlannedLifecycleBatch({
settlementId: `unheld-turns:${sessionId}:${fence}:${generation}`,
fence,
recovered: true,
plan: () => ({
mutations: runningTurnLifecycleRevisions(unheld, { state: 'interrupted' }),
dispatches: []
})
})
}
@@ -58,65 +58,89 @@ async function startedWith(options: Record<string, string>): Promise<StructuredA
}
describe("a Claude chat whose saved model the CLI says doesn't exist", () => {
it('keeps the saved pick while the live child retires its missing model', async () => {
const host = await startedWith({ model: RETIRED, effort: 'high' })
expect(claude.child(SESSION).launch.options.model).toBe(RETIRED)
expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED)
it.each([undefined, 'opus[1m]'])(
'keeps the saved pick and applies the replacement %s after an unsolicited model error',
async (replacement) => {
const host = await startedWith({ model: RETIRED, effort: 'high' })
expect(claude.child(SESSION).launch.options.model).toBe(RETIRED)
expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED)
claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1'))
await host.flushStreamedEvents(SESSION)
expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({
model: RETIRED,
effort: 'high'
})
const live = await host.deps.adapter.readOptions?.({
sessionId: SESSION,
fence: host.deps.store.getRecord(SESSION)!.lease.runtimeFence
})
expect(live).toMatchObject({ current: { model: undefined } })
const answer = await vi.waitFor(async () => {
const read = await host.readOptions(SESSION, { readsWithoutModel: true })
expect(read.current.model).not.toBe(RETIRED)
expect(read.current).toMatchObject({ model: 'sonnet', defaulted: [] })
return read
})
const seed = structuredAgentSessionSeedCatalog('claude')
const reopened = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('claude', seed),
seed,
answer
)
const options = structuredAgentSessionSendSelection(reopened)
expect(options).toEqual({ model: answer.current.model, effort: 'high' })
const dispatch = vi.spyOn(host.deps.adapter, 'dispatch')
const setModel = vi.spyOn(claude.child(SESSION).connection, 'setModel')
const body = hostTestMessage('try the replacement')
const sent = await host.send(CALLER, {
envelope: {
claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1'))
await host.flushStreamedEvents(SESSION)
expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({
model: RETIRED,
effort: 'high'
})
const live = await host.deps.adapter.readOptions?.({
sessionId: SESSION,
clientOperationId: `${Date.now()}-${'2'.padStart(32, '0')}`,
expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: SESSION,
fields: { body, options }
fence: host.deps.store.getRecord(SESSION)!.lease.runtimeFence
})
expect(live).toMatchObject({ current: { model: undefined } })
const answer = await vi.waitFor(async () => {
const read = await host.readOptions(SESSION, { readsWithoutModel: true })
expect(read.current.model).not.toBe(RETIRED)
expect(read.current).toMatchObject({ model: 'sonnet', defaulted: [] })
return read
})
const seed = structuredAgentSessionSeedCatalog('claude')
const reopened = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('claude', seed),
seed,
answer
)
const options = {
...structuredAgentSessionSendSelection(reopened),
...(replacement ? { model: replacement } : {})
}
expect(options).toEqual({ model: replacement ?? answer.current.model, effort: 'high' })
const dispatch = vi.spyOn(host.deps.adapter, 'dispatch')
const connection = claude.child(SESSION).connection
let appliedModel: string | undefined = 'sonnet'
const originalSetModel = connection.setModel
const setModel = vi
.spyOn(connection, 'setModel')
.mockImplementation(async (model, options) => {
await originalSetModel(model, options)
appliedModel = model
})
},
body,
options
})
expect(sent).toMatchObject({ ok: true })
await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send'))
expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({
model: answer.current.model,
effort: 'high'
})
expect(setModel.mock.calls.map(([model]) => model)).toEqual([answer.current.model])
expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({
model: RETIRED,
effort: 'high'
})
})
const originalSend = connection.send
const modelsAtWrite: (string | undefined)[] = []
vi.spyOn(connection, 'send').mockImplementation(async (message, prepare) => {
await prepare?.()
modelsAtWrite.push(appliedModel)
return originalSend(message)
})
const body = hostTestMessage('try the replacement')
const sent = await host.send(CALLER, {
envelope: {
sessionId: SESSION,
clientOperationId: `${Date.now()}-${'2'.padStart(32, '0')}`,
expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: SESSION,
fields: { body, options }
})
},
body,
options
})
expect(sent).toMatchObject({ ok: true })
await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send'))
expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({
model: replacement ?? answer.current.model,
effort: 'high'
})
expect(setModel.mock.calls.map(([model]) => model)).toEqual([
replacement ?? answer.current.model
])
expect(modelsAtWrite).toEqual([replacement ?? answer.current.model])
expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({
model: RETIRED,
effort: 'high'
})
}
)
it("keeps a model the reply did not come from: a subagent's own error", async () => {
const host = await startedWith({ model: RETIRED })
@@ -226,7 +226,10 @@ describe('a permission pick on this device', () => {
)
const modelWrite = Promise.withResolvers<{ options: Record<string, string> }>()
const modeWrite = Promise.withResolvers<{ options: Record<string, string> }>()
vi.mocked(mutate).mockReturnValueOnce(modelWrite.promise).mockReturnValueOnce(modeWrite.promise)
vi.mocked(mutate)
.mockReturnValueOnce(modelWrite.promise)
.mockReturnValueOnce(modeWrite.promise)
.mockResolvedValue({ options: { permissionMode: 'auto' } })
const claude = renderReopened(1, 'claude')
await waitFor(() => expect(permission(claude.result.current.optionSnapshot)).toBe('ask'))
const choices = (snapshot: readonly SessionOptionDescriptor[]) => {
@@ -242,11 +245,15 @@ describe('a permission pick on this device', () => {
void captured('permissionMode', 'full-access')
})
expect(permission(claude.result.current.optionSnapshot)).toBe('full-access')
expect(choices(claude.result.current.optionSnapshot)).not.toContain('auto')
expect(await captured('permissionMode', 'auto')).toBe(false)
const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto')
expect(choices(returned.snapshot)).not.toContain('auto')
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(4)
expect(choices(claude.result.current.optionSnapshot)).toContain('auto')
await act(async () => {
expect(await captured('permissionMode', 'auto')).toBe(true)
})
await act(async () => {
const returned = await claude.result.current.optionSurface.setOption('permissionMode', 'auto')
expect(choices(returned.snapshot)).toContain('auto')
})
expect(vi.mocked(mutate)).toHaveBeenCalledTimes(6)
model = 'model-b'
await act(async () => modeWrite.resolve({ options: { permissionMode: 'full-access' } }))
await act(async () => modelWrite.resolve({ options: { model } }))
+5 -61
View File
@@ -2,11 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
AGENT_CHAT_PERMISSION_MODES,
agentChatPermissionModes,
chatModelSupportsAutoMode,
chatPermissionModeNeedsModelSupport,
effectiveChatPermissionMode,
newChatPermissionMode,
offeredChatPermissionModes
savedChatPermissionMode
} from './agent-chat-permission-mode'
const CLAUDE_CHAT_PERMISSION_MODES = agentChatPermissionModes('claude')!
@@ -27,7 +24,6 @@ describe('agent chat permission modes', () => {
it('seeds a new chat from the host setting, narrowed to what its agent offers', () => {
expect(newChatPermissionMode('codex', 'accept-edits')).toBe('ask')
expect(newChatPermissionMode('claude', 'accept-edits')).toBe('accept-edits')
// Claude's auto is narrowed at launch by the model, so the chat keeps its choice.
expect(newChatPermissionMode('claude', 'auto')).toBe('auto')
expect(newChatPermissionMode('codex', 'full-access')).toBe('full-access')
expect(newChatPermissionMode('claude', 'retired-mode')).toBe('ask')
@@ -39,62 +35,10 @@ describe('agent chat permission modes', () => {
})
describe('the mode a chat runs', () => {
it('runs Claude auto only on a model listed as supporting it', () => {
expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', true)).toBe('auto')
expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', false)).toBe('ask')
expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'auto')).toBe(true)
expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'ask')).toBe(false)
})
it('runs Codex auto on any model', () => {
expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'auto', false)).toBe('auto')
expect(chatPermissionModeNeedsModelSupport(CODEX_CHAT_PERMISSION_MODES, 'auto')).toBe(false)
})
it('asks for a chat saved before modes existed, or with a value its agent cannot run', () => {
expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, undefined, true)).toBe('ask')
expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'accept-edits', true)).toBe(
'ask'
)
expect(
effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'bypassPermissions', true)
).toBe('ask')
})
it('offers Approve for me only where the model runs it', () => {
expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, false)).toEqual([
'ask',
'accept-edits',
'full-access'
])
expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, true)).toEqual(
AGENT_CHAT_PERMISSION_MODES
)
expect(offeredChatPermissionModes(CODEX_CHAT_PERMISSION_MODES, false)).toEqual([
'ask',
'auto',
'full-access'
])
})
})
describe('the model permission catalog', () => {
// The catalog shape `claude` 2.1.280 lists: the default row is folded into the row it resolves to.
it('reads auto support from the chat model row, or the CLI default when it names none', () => {
const models = [
{ id: 'opus[1m]', isDefault: true, supportsAutoMode: true },
{ id: 'sonnet', isDefault: false, supportsAutoMode: true },
{ id: 'haiku', isDefault: false }
]
for (const model of [undefined, null, 'default', 'opus[1m]', 'sonnet']) {
expect(chatModelSupportsAutoMode(models, model)).toBe(true)
}
for (const model of ['haiku', 'claude-opus-5-5[1m]', 'retired-model']) {
expect(chatModelSupportsAutoMode(models, model)).toBe(false)
}
// No row the CLI runs by default: unknown, so no auto.
expect(chatModelSupportsAutoMode([{ id: 'opus[1m]', supportsAutoMode: true }], undefined)).toBe(
false
)
expect(savedChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, undefined)).toBe('ask')
expect(savedChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, null)).toBe('ask')
expect(savedChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'accept-edits')).toBe('ask')
expect(savedChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'bypassPermissions')).toBe('ask')
})
})
-41
View File
@@ -71,47 +71,6 @@ export function savedChatPermissionMode(
return support.modes.find((mode) => mode === intent) ?? 'ask'
}
/** Launch and menu share model auto support: use its listed row, or the listed CLI default when unnamed. */
export function chatModelSupportsAutoMode(
models: readonly { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[],
model: string | null | undefined
): boolean {
const row = models.find((entry) =>
model === undefined || model === null || model === 'default'
? entry.isDefault === true
: entry.id === model
)
return row?.supportsAutoMode === true
}
export function chatPermissionModeNeedsModelSupport(
support: AgentChatPermissionModeSupport,
intent: unknown
): boolean {
return (
support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto'
)
}
/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */
export function effectiveChatPermissionMode(
support: AgentChatPermissionModeSupport,
intent: unknown,
autoSupported: boolean
): AgentChatPermissionChoice {
const mode = savedChatPermissionMode(support, intent)
return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode
}
export function offeredChatPermissionModes(
support: AgentChatPermissionModeSupport,
autoSupported: boolean
): readonly AgentChatPermissionMode[] {
return support.autoNeedsModelSupport && !autoSupported
? support.modes.filter((mode) => mode !== 'auto')
: support.modes
}
export function newChatPermissionMode(
agent: string,
setting: unknown
+1 -4
View File
@@ -2,13 +2,10 @@ import type { AgentChatPermissionMode } from './agent-chat-permission-mode'
/** The modes one agent's chats offer. */
export type AgentChatPermissionModeSupport = {
modes: readonly AgentChatPermissionMode[]
/** `auto` runs only on a model the agent's listing says supports it. */
autoNeedsModelSupport?: true
}
const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
modes: ['ask', 'accept-edits', 'auto', 'full-access'],
autoNeedsModelSupport: true
modes: ['ask', 'accept-edits', 'auto', 'full-access']
}
const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = {
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import type { AgentSessionOptionsResult } from './agent-session-wire'
import { sortNativeChatSessionOptions } from './native-chat-session-option-snapshot'
import {
applyStructuredAgentSessionModelCatalog,
applyStructuredAgentSessionOptions,
commitStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
@@ -98,60 +97,21 @@ describe('the permission mode picker', () => {
])
})
it("shows the host's mode as stated, and offers Claude auto only on a model listed for it", () => {
const offered = (autoMode: boolean | undefined, mode = 'ask') => {
const descriptor = permission(live('claude', { permissionMode: mode }, autoMode))
return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value)
}
expect(offered(true)).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
expect(offered(false)).toEqual(['ask', 'accept-edits', 'full-access'])
expect(offered(undefined)).toEqual(['ask', 'accept-edits', 'full-access'])
// The host decides what runs; a client catalog that cannot confirm it never relabels it.
it('offers all Claude modes regardless of catalog support, and shows the host mode as stated', () => {
const unsupported = permission(live('claude', { permissionMode: 'ask' }, false))
expect(
unsupported?.kind.type === 'select' && unsupported.kind.choices.map((c) => c.value)
).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
expect(permission(live('claude', { permissionMode: 'auto' }, false))).toMatchObject({
kind: { currentValue: 'auto' }
})
expect(offered(false, 'auto')).toEqual(['ask', 'accept-edits', 'auto', 'full-access'])
const changing = { ...live('claude', { permissionMode: 'ask' }, true), pendingId: 'model' }
expect(permission(changing)?.kind).toMatchObject({
choices: expect.not.arrayContaining([expect.objectContaining({ value: 'auto' })])
})
expect(permission({ ...changing, permissionMode: 'auto' })?.kind).toMatchObject({
currentValue: 'auto',
choices: expect.arrayContaining([expect.objectContaining({ value: 'auto' })])
})
})
// A new chat names no model: the host's catalog row for the CLI default decides, as the host's does.
it('offers Claude auto to a new chat whose CLI default the host lists as running it', () => {
const seed = structuredAgentSessionSeedCatalog('claude')
const offered = (rows: { id: string; isDefault: boolean; supportsAutoMode?: boolean }[]) => {
const state = applyStructuredAgentSessionModelCatalog(
createStructuredAgentSessionOptionState('claude', seed),
seed,
{
origin: 'probe',
fetchedAt: 1,
models: rows.map((row) => ({ ...row, label: row.id, efforts: [] }))
},
{ newLaunch: true }
)
const descriptor = permission(
structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {})
)
return descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value)
}
const state = createStructuredAgentSessionOptionState('claude')
const descriptor = permission(
structuredAgentSessionOptionView(state, { permissionMode: 'ask' }, {})
)
expect(
offered([
{ id: 'opus[1m]', isDefault: true, supportsAutoMode: true },
{ id: 'haiku', isDefault: false }
])
descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value)
).toContain('auto')
expect(
offered([
{ id: 'opus[1m]', isDefault: false, supportsAutoMode: true },
{ id: 'haiku', isDefault: true }
])
).not.toContain('auto')
})
it("shows a held pick over the host's value, and the host's over the seed", () => {
@@ -3,11 +3,8 @@
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
agentChatPermissionModes,
chatModelSupportsAutoMode,
offeredChatPermissionModes,
type AgentChatPermissionMode
} from './agent-chat-permission-mode'
import type { AgentSessionOptionCatalog } from './agent-session-option-catalog'
import type { StructuredAgentSessionOptionRecord } from './structured-agent-session-option-state'
import type { SessionOptionDescriptor } from './native-chat-session-options'
@@ -54,22 +51,9 @@ function agentDescriptions(agent: string): Partial<Record<AgentChatPermissionMod
return AGENT_DESCRIPTIONS.get(agent) ?? OTHER_AGENT_DESCRIPTIONS
}
function currentModelSupportsAutoMode(
catalog: AgentSessionOptionCatalog | null,
record: StructuredAgentSessionOptionRecord
): boolean {
const model = record.values.model?.value
return (
catalog !== null &&
chatModelSupportsAutoMode(catalog.models, typeof model === 'string' ? model : undefined)
)
}
export function structuredAgentSessionPermissionDescriptor(state: {
catalog: AgentSessionOptionCatalog | null
record: StructuredAgentSessionOptionRecord
permissionMode: string | null
pendingId?: string | null
permissionOwnSupported?: boolean | null
reportedPermissionMode?: string | null
}): SessionOptionDescriptor | null {
@@ -82,12 +66,6 @@ export function structuredAgentSessionPermissionDescriptor(state: {
state.reportedPermissionMode === undefined && !own && state.permissionOwnSupported !== true
? (state.permissionMode ?? undefined)
: (state.reportedPermissionMode ?? undefined)
const offered = offeredChatPermissionModes(
support,
state.permissionMode === 'auto' ||
current === 'auto' ||
(state.pendingId !== 'model' && currentModelSupportsAutoMode(state.catalog, state.record))
)
const reported = support.modes.find((mode) => mode === current)
const label = reported
? MODE_COPY[reported].label
@@ -104,7 +82,7 @@ export function structuredAgentSessionPermissionDescriptor(state: {
kind: {
type: 'select',
currentValue: current,
choices: offered.map((value) => ({
choices: support.modes.map((value) => ({
value,
label: MODE_COPY[value].label,
description: agentDescriptions(state.record.agent)[value] ?? MODE_COPY[value].description
@@ -204,17 +204,30 @@ const optionReads = () =>
mocks.rpc.mock.calls.filter(([, method]) => method === 'agentSession.options').length
describe('Approve for me on a new Claude chat', () => {
it('is offered once the background listing lands after the chat opened', async () => {
it('is offered before and after the background listing lands', async () => {
const lands = new Promise<void>((resolve) => (release = resolve))
await hostCatalog(lands)
const { result, unmount } = openNewChat()
// The chat opened before its account was ever listed: the first answers come without a list.
await waitFor(() => expect(optionReads()).toBe(1))
await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('ask'))
expect(offered(result.current.optionSnapshot)).not.toContain('auto')
expect(offered(result.current.optionSnapshot)).toEqual([
'ask',
'accept-edits',
'auto',
'full-access'
])
release()
await waitFor(() => expect(offered(result.current.optionSnapshot)).toContain('auto'))
await mocks.rpc(undefined, 'agentSession.modelCatalog', { waitForListing: true })
await waitFor(() =>
expect(offered(result.current.optionSnapshot)).toEqual([
'ask',
'accept-edits',
'auto',
'full-access'
])
)
unmount()
})