mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
+208
-10
@@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
+7
-2
@@ -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: (
|
||||
|
||||
+206
-1
@@ -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)
|
||||
|
||||
+12
-92
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-30
@@ -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. */
|
||||
|
||||
+1
-84
@@ -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 })
|
||||
|
||||
+13
-6
@@ -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 } }))
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user