From 63425b63e3e8f7a766adc4de6ace1c1f6b52f7a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:08:25 -0700 Subject: [PATCH 01/16] fix(native-chat): a Claude chat saved as Approve for me keeps it when Orca can't read its model list A chat's auto mode dropped to Ask whenever the saved model list was missing or couldn't name the chat's model (another account home, first start in a folder, an unlisted model id), so a resumed chat launched asking for approval. Auto now drops to Ask only when the list shows the model without it; Claude itself starts a model that can't run auto in its default mode. Tracked in STA-10953. --- ...ed-agent-session-launch-permission.test.ts | 36 ++++++++------- ...uctured-agent-session-launch-permission.ts | 15 ++++--- ...tured-agent-session-outgrown-child.test.ts | 34 ++++++-------- src/shared/agent-chat-permission-mode.test.ts | 8 ++-- src/shared/agent-chat-permission-mode.ts | 44 ++++++++++++++----- 5 files changed, 78 insertions(+), 59 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 415cedef7a92..2f0af4a0d6fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -1,5 +1,5 @@ -// The mode a start launches with: Claude's Approve for me needs its model listed for it in the saved -// catalog; anything the catalog cannot confirm launches Ask. +// The mode a start launches with: Claude's Approve for me drops to Ask only on a model the saved +// catalog lists without it; a catalog that can't say keeps the saved choice. import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -78,29 +78,31 @@ describe('the permission mode a chat starts with', () => { 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 on a model listed without auto, or a chat with no model whose listed default lacks it', async () => { + const rows = listing([ + { id: 'sonnet', isDefault: true }, + { id: 'opus', supportsAutoMode: true }, + { id: 'haiku' } + ]) + for (const options of [{ model: 'haiku' }, {}]) { + await expect( + launchPermission('claude', { permissionMode: 'auto', ...options }, rows).result + ).resolves.toBe('ask') + } }) - it('asks when the catalog cannot confirm the model runs auto', async () => { + // #30032: a catalog saved under another account home read as none, and the chat relaunched asking. + it('keeps the saved auto when the catalog cannot say whether the model runs it', async () => { for (const answer of [ { origin: 'unknown' } as const, - listing([{ id: 'opus' }]), + new Error('catalog unavailable'), listing([{ id: 'sonnet', supportsAutoMode: true }]), - new Error('catalog unavailable') + // A CLI that doesn't mark auto support at all. + listing([{ id: 'opus' }, { id: 'sonnet' }]) ]) { await expect( launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result - ).resolves.toBe('ask') + ).resolves.toBe('auto') } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index b77f5cc9984b..b752ea127e4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -3,7 +3,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentChatPermissionModes, - chatModelSupportsAutoMode, + chatModelListedWithoutAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, type AgentChatPermissionMode @@ -11,8 +11,9 @@ import { import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' /** Saved catalog only, so a start never waits on a listing; for the chat's own folder, as its - * options read names its default. */ -async function listedAutoModeSupport( + * options read names its default. A missing catalog keeps the saved choice: Claude itself starts + * a model that can't run `auto` in its default mode. */ +async function listedWithoutAutoMode( catalog: StructuredAgentSessionHostDeps['modelCatalog'], record: AgentSessionRecord ): Promise { @@ -28,7 +29,7 @@ async function listedAutoModeSupport( listing !== null && listing !== undefined && listing.origin !== 'unknown' && - chatModelSupportsAutoMode(listing.models, record.options?.model) + chatModelListedWithoutAutoMode(listing.models, record.options?.model) ) } @@ -42,8 +43,8 @@ export async function structuredAgentSessionLaunchPermission( return null } const intent = record.permissionMode - const autoSupported = + const listedWithoutAuto = chatPermissionModeNeedsModelSupport(support, intent) && - (await listedAutoModeSupport(deps.modelCatalog, record)) - return effectiveChatPermissionMode(support, intent, autoSupported) + (await listedWithoutAutoMode(deps.modelCatalog, record)) + return effectiveChatPermissionMode(support, intent, listedWithoutAuto) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index e163496614d9..6369245f5d41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -326,17 +326,19 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { (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('restarts Full access or Accept edits into Ask on a model listed without auto', async () => { + const autoPick = launch({ model: 'haiku', permissionMode: 'auto' }) + 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('restarts into auto once the model is no longer listed without it', async () => { + const onHaiku = await launch({ model: 'haiku', permissionMode: 'auto' })() + const onSonnet = launch({ model: 'sonnet', permissionMode: 'auto' }) + expect(await stoppedAfterLaunch(onHaiku!, onSonnet)).toBe(true) + expect(await stoppedAfterLaunch((await onSonnet())!, onSonnet)).toBe(false) }) it('never restarts a child it just started, from an alias or a resolved id', async () => { @@ -348,7 +350,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { { model: 'haiku', permissionMode: 'auto' }, { model: 'claude-opus-5-5[1m]', permissionMode: 'auto' } ] - const runsAuto = [true, true, true, true, false, false] + const runsAuto = [true, true, true, true, false, true] for (const [index, options] of records.entries()) { const current = launch(options) const launched = await current() @@ -356,14 +358,6 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { expect(await stoppedAfterLaunch(launched!, current)).toBe(false) } }) - - // A resolved id launches Ask; the start reports the listed alias, so one restart reaches auto. - 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 () => { diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index f4d097a43683..9016e93a9086 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -42,15 +42,15 @@ 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') + it('runs Claude auto unless its model is listed without it', () => { + expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', false)).toBe('auto') + expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', true)).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(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'auto', true)).toBe('auto') expect(chatPermissionModeNeedsModelSupport(CODEX_CHAT_PERMISSION_MODES, 'auto')).toBe(false) }) diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 2a40715f2d05..240d37f44a53 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -24,7 +24,7 @@ export function isAgentChatPermissionMode(value: unknown): value is AgentChatPer /** 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. */ + /** `auto` doesn't run on a model the agent's listing shows without it. */ autoNeedsModelSupport?: true } @@ -70,18 +70,40 @@ export function savedChatPermissionMode( return support.modes.find((mode) => mode === intent) ?? 'ask' } -/** The one rule for whether the chat's model runs `auto`: its listed row, or the listed CLI default - * when it names none. The host launches with it and the menu offers with it. */ -export function chatModelSupportsAutoMode( - models: readonly { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[], +type ListedChatModel = { id: string; isDefault?: boolean; supportsAutoMode?: boolean } + +/** The chat's model's listed row, or the listed CLI default when it names none. */ +function listedChatModelRow( + models: readonly ListedChatModel[], model: string | null | undefined -): boolean { - const row = models.find((entry) => +): ListedChatModel | undefined { + return models.find((entry) => model === undefined || model === null || model === 'default' ? entry.isDefault === true : entry.id === model ) - return row?.supportsAutoMode === true +} + +/** Whether the menu offers `auto`: the chat's model is listed as running it. */ +export function chatModelSupportsAutoMode( + models: readonly ListedChatModel[], + model: string | null | undefined +): boolean { + return listedChatModelRow(models, model)?.supportsAutoMode === true +} + +/** Whether the listing shows the chat's model can't run `auto`; a listing that can't say is not + * evidence. Claude marks only the models that run it, so a listing marking none says nothing. */ +export function chatModelListedWithoutAutoMode( + models: readonly ListedChatModel[], + model: string | null | undefined +): boolean { + const row = listedChatModelRow(models, model) + return ( + row !== undefined && + row.supportsAutoMode !== true && + models.some((entry) => entry.supportsAutoMode === true) + ) } /** Whether the mode a chat runs depends on its model's listed `auto` support. */ @@ -94,14 +116,14 @@ export function chatPermissionModeNeedsModelSupport( ) } -/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */ +/** Saved choice, with `auto` on a model listed without it read as Ask; never written back. */ export function effectiveChatPermissionMode( support: AgentChatPermissionModeSupport, intent: unknown, - autoSupported: boolean + listedWithoutAuto: boolean ): AgentChatPermissionMode { const mode = savedChatPermissionMode(support, intent) - return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode + return mode === 'auto' && support.autoNeedsModelSupport && listedWithoutAuto ? 'ask' : mode } export function offeredChatPermissionModes( From 38442eef792128e10aaa761aa55f48e2dd7564a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:12:43 -0700 Subject: [PATCH 02/16] fix(native-chat): a model list from a CLI older than auto mode still reads as Ask A CLI that marks no model with auto support predates the auto value (2.1.71) and would refuse --permission-mode auto, so its listing is evidence, not a gap. Tracked in STA-10953. --- ...tructured-agent-session-launch-permission.test.ts | 12 +++++++++--- src/shared/agent-chat-permission-mode.ts | 10 +++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 2f0af4a0d6fb..cade40a6967a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -89,6 +89,14 @@ describe('the permission mode a chat starts with', () => { launchPermission('claude', { permissionMode: 'auto', ...options }, rows).result ).resolves.toBe('ask') } + // A CLI that marks no model predates auto (2.1.71) and would refuse it. + await expect( + launchPermission( + 'claude', + { permissionMode: 'auto', model: 'opus' }, + listing([{ id: 'opus' }, { id: 'sonnet' }]) + ).result + ).resolves.toBe('ask') }) // #30032: a catalog saved under another account home read as none, and the chat relaunched asking. @@ -96,9 +104,7 @@ describe('the permission mode a chat starts with', () => { for (const answer of [ { origin: 'unknown' } as const, new Error('catalog unavailable'), - listing([{ id: 'sonnet', supportsAutoMode: true }]), - // A CLI that doesn't mark auto support at all. - listing([{ id: 'opus' }, { id: 'sonnet' }]) + listing([{ id: 'sonnet', supportsAutoMode: true }]) ]) { await expect( launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 240d37f44a53..88f72e888b76 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -92,18 +92,14 @@ export function chatModelSupportsAutoMode( return listedChatModelRow(models, model)?.supportsAutoMode === true } -/** Whether the listing shows the chat's model can't run `auto`; a listing that can't say is not - * evidence. Claude marks only the models that run it, so a listing marking none says nothing. */ +/** Whether the listing shows the chat's model can't run `auto`. Claude marks only the models that + * run it, and a CLI that marks none predates `auto`; a model the listing lacks is not evidence. */ export function chatModelListedWithoutAutoMode( models: readonly ListedChatModel[], model: string | null | undefined ): boolean { const row = listedChatModelRow(models, model) - return ( - row !== undefined && - row.supportsAutoMode !== true && - models.some((entry) => entry.supportsAutoMode === true) - ) + return row !== undefined && row.supportsAutoMode !== true } /** Whether the mode a chat runs depends on its model's listed `auto` support. */ From 75e4057726df1ccaf320b4bc1d1271cba654f406 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:37:34 -0700 Subject: [PATCH 03/16] docs(chat): correct auto support reporting version --- .../structured-agent-session-launch-permission.test.ts | 2 +- src/shared/agent-chat-permission-mode.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index cade40a6967a..bf0b48f61dfd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -89,7 +89,7 @@ describe('the permission mode a chat starts with', () => { launchPermission('claude', { permissionMode: 'auto', ...options }, rows).result ).resolves.toBe('ask') } - // A CLI that marks no model predates auto (2.1.71) and would refuse it. + // A CLI before 2.1.72 does not report model auto support; the auto value arrived in 2.1.71. await expect( launchPermission( 'claude', diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 88f72e888b76..6468b0e7c3ab 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -92,8 +92,8 @@ export function chatModelSupportsAutoMode( return listedChatModelRow(models, model)?.supportsAutoMode === true } -/** Whether the listing shows the chat's model can't run `auto`. Claude marks only the models that - * run it, and a CLI that marks none predates `auto`; a model the listing lacks is not evidence. */ +/** Whether the listing shows the chat's model can't run `auto`. Claude marks only supporting models. + * A CLI before 2.1.72 does not report model auto support; an unlisted model is not evidence. */ export function chatModelListedWithoutAutoMode( models: readonly ListedChatModel[], model: string | null | undefined From 89f332dd27b6036d8312b1c2c4af8965f4183941 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:40:11 -0700 Subject: [PATCH 04/16] test(chat): type saved launch option cases --- .../structured-agent-session-launch-permission.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index bf0b48f61dfd..41574c0ac767 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -84,7 +84,8 @@ describe('the permission mode a chat starts with', () => { { id: 'opus', supportsAutoMode: true }, { id: 'haiku' } ]) - for (const options of [{ model: 'haiku' }, {}]) { + const savedOptions: Record[] = [{ model: 'haiku' }, {}] + for (const options of savedOptions) { await expect( launchPermission('claude', { permissionMode: 'auto', ...options }, rows).result ).resolves.toBe('ask') From 84554ad4b882b8874602f6de06b98c6e5881a89c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:17:44 -0700 Subject: [PATCH 05/16] Honor requested chat permissions across model changes --- ...laude-structured-launch-resolution.test.ts | 21 +- ...-structured-options-context-window.test.ts | 1 + src/main/claude/claude-structured-options.ts | 20 ++ .../claude-structured-session-acquisition.ts | 14 +- .../claude-structured-session-adapter.test.ts | 83 ++++++ .../claude-structured-session-adapter.ts | 5 +- .../claude/claude-structured-session-state.ts | 3 + .../claude/claude-structured-spawn-options.ts | 19 +- ...structured-agent-session-adapter-router.ts | 4 + .../structured-agent-session-adapter.ts | 6 + ...-agent-session-claude-subagent-row.test.ts | 246 +++++++++++++++++- .../structured-agent-session-delivery-loop.ts | 71 +++-- .../structured-agent-session-host-delivery.ts | 12 +- ...structured-agent-session-host-mutations.ts | 2 + ...ed-agent-session-launch-permission.test.ts | 100 +++---- ...uctured-agent-session-launch-permission.ts | 35 +-- ...structured-agent-session-mutation-plans.ts | 2 + ...tured-agent-session-outgrown-child.test.ts | 80 +----- ...structured-agent-session-outgrown-child.ts | 88 ++++++- ...uctured-agent-session-start-failure-row.ts | 25 ++ .../structured-agent-session-turns.ts | 8 +- .../runtime/orchestration/send-agent-turn.ts | 4 +- .../structured-mailbox-pointer-host.ts | 1 + src/shared/agent-chat-permission-mode.test.ts | 64 +---- src/shared/agent-chat-permission-mode.ts | 66 +---- src/shared/agent-session-failure-words.ts | 4 + ...ed-agent-session-permission-option.test.ts | 52 +--- ...uctured-agent-session-permission-option.ts | 22 +- 28 files changed, 632 insertions(+), 426 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 63c6ac8ba126..5b1923795415 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -292,12 +292,21 @@ describe('claude structured launch resolution', () => { ['ask', 'default'], ['accept-edits', 'acceptEdits'], ['auto', 'auto'] - ] as const)('starts a %s chat in permission mode %s', async (permissionMode, expected) => { - const launch = await resolverFor(record())({ identity: IDENTITY, permissionMode }) - - expect(launch.options.permissionMode).toBe(expected) - expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) - }) + ] as const)( + 'starts and resumes a %s chat in permission mode %s', + async (permissionMode, expected) => { + const launches = [ + await resolverFor(record())({ identity: IDENTITY, permissionMode }), + await resolverFor(RESUMABLE)({ identity: identityAt('leaf-current'), permissionMode }) + ] + + expect(launches[1]?.options.resume).toBe('provider-current') + for (const launch of launches) { + expect(launch.options.permissionMode).toBe(expected) + expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) + } + } + ) // The chat's own mode replaces Agent Permissions: a Yolo terminal setting grants a chat nothing. it('ignores the bypass flag the agent Arguments carry', async () => { diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index eab0aef168d9..d0b2eb01c7e9 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -35,6 +35,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 }) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index d6612a759175..1a79cd6e8050 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -11,12 +11,32 @@ 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 +export function claudeModelTransitionRestart( + sessions: ReadonlyMap +): NonNullable { + return (input) => { + const session = sessions.get(input.sessionId) + const model = input.executionOptions?.model + const opened = session?.firstRootInit?.permissionMode + return Boolean( + session?.fence === input.fence && + model !== undefined && + session.launchPermissionMode !== undefined && + opened !== undefined && + opened !== null && + opened !== session.launchPermissionMode && + session.appliedInputOptions.get('model') !== model + ) + } +} + export function restoredClaudeStructuredSessionOptions( options: Readonly> | undefined ): Map { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 9b48879d000b..88023e474f57 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -39,6 +39,7 @@ import { bindClaudeConnectionJournalControls, createClaudeSessionJournalTranslator } from './claude-structured-session-journal-control' +import { isRootClaudeFrame } from './claude-turn-opening' export async function acquireClaudeSession({ input, @@ -60,6 +61,7 @@ export async function acquireClaudeSession({ const { previous, attempt } = acquisitions.start(sessionId, prompts) let unbindReadingControl: (() => void) | undefined let liveSession: ClaudeSession | null = null + let firstRootInit: ClaudeSession['firstRootInit'] let observedLeafUuid: string | null = null, expectedProviderSessionId: string | null = null // The CLI's own account of why it ended (stderr included): the only reason a user can act on. @@ -91,6 +93,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 modelChanged = liveSession && init?.model ? observeClaudeExecutionModel(liveSession, init.model) : false if (init) { @@ -264,7 +272,11 @@ export async function acquireClaudeSession({ }) const session = publication.session 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) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index b470c1f5f482..6d8e456c3495 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -27,6 +27,89 @@ import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter.acquire', () => { + it('keeps the first root init and successful literal selector separate from hooks and reports', async () => { + const claude = fakeClaude() + const open = claude.openConnection + claude.openConnection = async (launch, handlers) => { + const connection = await open(launch, handlers) + const frame = { type: 'system', session_id: PROVIDER_SESSION_ID } + handlers?.onMessage?.({ + ...frame, + subtype: 'hook_started', + hook_name: 'SessionStart:startup' + }) + handlers?.onMessage?.({ + ...frame, + subtype: 'init', + parent_tool_use_id: 'child', + permissionMode: 'auto' + }) + handlers?.onMessage?.({ + ...frame, + subtype: 'init', + permissionMode: 'default', + model: 'claude-haiku-4-5' + }) + return connection + } + const adapter = adapterFor(claude, { + options: { permissionMode: 'auto', extraArgs: { model: 'haiku' } } + }) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const requiresRestart = (model?: string) => + adapter.requiresModelTransitionRestart({ + sessionId: 'session-1', + fence: 7, + executionOptions: model === undefined ? {} : { model } + }) + expect(requiresRestart('haiku')).toBe(false) + expect(requiresRestart()).toBe(false) + expect(requiresRestart('opus')).toBe(true) + for (const subtype of ['status', 'init']) { + claude.connections[0].handlers.onMessage?.({ + type: 'system', + subtype, + session_id: PROVIDER_SESSION_ID, + permissionMode: 'auto', + model: 'opus' + }) + } + expect(requiresRestart('opus')).toBe(true) + await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'opus' }) + expect(requiresRestart('opus')).toBe(false) + expect(requiresRestart('haiku')).toBe(true) + claude.routes.set_model = () => { + throw new Error('control reply lost') + } + await expect( + adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'haiku' }) + ).rejects.toThrow('control reply lost') + expect(requiresRestart('opus')).toBe(true) + await adapter.closeSession('session-1') + }) + + it('does not let a later init fill the first root init missing permission', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { options: { permissionMode: 'auto' } }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'haiku' } + }) + const frame = { type: 'system', subtype: 'init', session_id: PROVIDER_SESSION_ID } + claude.connections[0].handlers.onMessage?.(frame) + claude.connections[0].handlers.onMessage?.({ ...frame, permissionMode: 'default' }) + expect( + adapter.requiresModelTransitionRestart({ + sessionId: 'session-1', + fence: 7, + executionOptions: { model: 'opus' } + }) + ).toBe(false) + await adapter.closeSession('session-1') + }) + it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 417bb364397a..adcc97ad8765 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -11,7 +11,7 @@ import { 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 { @@ -221,6 +221,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda const session = this.sessions.get(sessionId) return session ? claudeHoldsDispatch(session) : false } + requiresModelTransitionRestart = claudeOptions.claudeModelTransitionRestart(this.sessions) holdsLiveProviderProcess = (sessionId: string, acquisitionGeneration: string): boolean => { const session = this.sessions.get(sessionId) return ( @@ -232,7 +233,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 diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 33973db35288..f2101ebff9b6 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -184,6 +184,9 @@ export type ClaudeSession = { appliedInputOptions: Map /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null + /** Construction intent and the first admitted root init belong to this process only. */ + launchPermissionMode?: 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 needs confirmation at message input. */ diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index 7222b458cc2f..5b141c1d17f0 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -90,11 +90,26 @@ export function claudeStructuredSpawnOptions(input: { export function adoptClaudeStructuredSpawnOptions( session: Pick< ClaudeSession, - 'translator' | 'launchedModel' | 'fastModeAtStart' | 'appliedInputOptions' + | 'translator' + | 'launchedModel' + | 'launchPermissionMode' + | 'fastModeAtStart' + | 'appliedInputOptions' >, - spawn: Pick + spawn: ClaudeStructuredSpawnOptions ): void { session.launchedModel = spawn.options.get('model') ?? null + const configuredModel = spawn.sdkOptions.extraArgs?.model + const modelInput = typeof configuredModel === 'string' ? configuredModel : spawn.sdkOptions.model + session.launchPermissionMode = Object.hasOwn( + spawn.sdkOptions.extraArgs ?? {}, + 'dangerously-skip-permissions' + ) + ? 'bypassPermissions' + : spawn.sdkOptions.permissionMode + if (modelInput !== undefined) { + session.appliedInputOptions.set('model', modelInput) + } session.fastModeAtStart = spawn.fastModeAtStart const fastMode = spawn.options.get('fastMode') if (fastMode !== undefined && !spawn.fastModeAtStart) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 30cc9d4ee47e..8369221b5c48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -114,6 +114,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi holdsDispatch = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + requiresModelTransitionRestart: NonNullable< + StructuredAgentSessionAdapter['requiresModelTransitionRestart'] + > = (input) => + this.liveOwnerOrNull(input.sessionId)?.requiresModelTransitionRestart?.(input) ?? false heldSends = (sessionId: string): ReadonlySet | undefined => this.liveOwnerOrNull(sessionId)?.heldSends?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 61dcbba56bcb..7b3aacc8b1cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -369,6 +369,12 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up * or a silent retry does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean + /** Host-local construction facts for the exact next input; absent providers keep their child. */ + requiresModelTransitionRestart?(input: { + sessionId: string + fence: number + executionOptions?: AgentSessionExecutionOptions + }): boolean /** The sends the live child still holds as its own work, by client message id; undefined when * this adapter cannot say, and the host reads an unanswered send as held. */ heldSends?(sessionId: string): ReadonlySet | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts index 5f608639aa59..c5e9ed368dd3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -24,6 +24,13 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { reconcileLifecycleMessage } from '../../runtime/orchestration/lifecycle-reconciliation' +import { OrchestrationStructuredMailboxPointerDelivery } from '../../runtime/orchestration/structured-mailbox-pointer-delivery' +import { createStructuredMailboxPointerHost } from '../../runtime/orchestration/structured-mailbox-pointer-host' +import { structuredWorkerOwesWork } from '../../runtime/structured-worker-custody' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' @@ -46,6 +53,7 @@ let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let claude: ReturnType let records: ReturnType +let orchestration: OrchestrationDb /** The host's clock, which stamps every child's start and end. */ let clock = NOW @@ -55,10 +63,11 @@ beforeEach(async () => { resetHostTestOperationIds() const log = recordingStructuredAgentSessionLogger() claude = fakeClaude({ replayUuid: null }) + orchestration = new OrchestrationDb(':memory:') adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ + resolveLaunch: async ({ permissionMode }) => ({ pathToClaudeCodeExecutable: 'claude', - options: {}, + options: claudeStructuredPermissionOptions(permissionMode ?? 'ask'), cwd: root, claudeConfigDir: join(root, 'claude-home'), providerSessionId: PROVIDER_SESSION_ID, @@ -94,11 +103,13 @@ beforeEach(async () => { logger: log.logger, // The host's child records live in a real hook server's store, as in production. statusSink: records.sink, + hasOpenDispatch: (record) => structuredWorkerOwesWork(orchestration, record), now: () => clock }) const params = hostTestAttachParams(null, { provider: 'claude', agent: 'claude', + options: { model: 'haiku', permissionMode: 'auto' }, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) @@ -107,8 +118,10 @@ beforeEach(async () => { }) afterEach(async () => { + setStructuredAgentSessionHost(null) await adapter.closeAll() await host.flushAllStreamedEvents() + orchestration.close() await rm(root, { recursive: true, force: true }) }) @@ -153,6 +166,235 @@ async function savedRows() { ) } +it('keeps worker mail and native results on the live process, then reopens for the captured user choice', async () => { + const db = orchestration + const runId = 'run_legacy_local' + const handle = 'structworker_transition' + const task = db.createTask({ runId, spec: 'Read the result and report completion.' }) + const { dispatch } = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle, + paneKey: null, + processIncarnation: `structured:${SESSION}`, + worktreeId: 'workspace-1', + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created', + hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) + db.markWorkerDispatchReady(dispatch.id) + expect(structuredWorkerOwesWork(db, store.getRecord(SESSION)!)).toBe(true) + const first = claude.connections[0]! + expect(first.launch.options).toMatchObject({ model: 'haiku', permissionMode: 'auto' }) + frame(first, { + type: 'system', + subtype: 'init', + uuid: 'first-root', + model: 'claude-haiku-4-5', + permissionMode: 'default' + }) + await sendAndEcho(first, 'Run it in the background.') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) { + frame(first, { uuid: `launch-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([expect.objectContaining({ state: 'working' })]) + ) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'opus' }, + now: clock + }) + const body = hostTestMessage('Use my new model now.') + const refused = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(refused.ok).toBe(true) + if (!refused.ok) { + throw new Error('send was not journaled') + } + await eventually(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (submission) => submission.clientMessageId === refused.value.clientMessageId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringContaining('Background tasks are still running.') + }) + ) + expect(first.closed).toBe(false) + expect(first.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + permissionMode: 'ask', + now: clock + }) + + setStructuredAgentSessionHost(host) + const mailboxHandle = `dispatch:${dispatch.id}` + const mail = db.insertMessage({ + runId, + from: 'coordinator', + to: mailboxHandle, + type: 'question', + subject: 'Result ready', + body: 'Consume this result and finish.', + deliveryContract: 'current_delivery' + }) + const pointers = new OrchestrationStructuredMailboxPointerDelivery({ + getDb: () => db, + getMessageWaiters: () => undefined, + resolveStructuredTarget: () => ({ sessionId: SESSION, dispatchId: dispatch.id }), + getCliCommand: () => 'orca-dev', + senderName: () => null, + host: createStructuredMailboxPointerHost() + }) + expect(pointers.deliverForHandle(mailboxHandle)).toBe(true) + await eventually(() => expect(first.sent).toHaveLength(2)) + frame(first, { ...first.sent.at(-1)!, uuid: 'mail-echo' }) + await eventually(() => expect(db.getMessageById(mail.id)?.delivered_at).not.toBeNull()) + expect(claude.connections).toHaveLength(1) + expect( + first.calls.some(({ subtype }) => subtype === 'set_model' || subtype === 'set_permission_mode') + ).toBe(false) + const pointer = (await host.journalSnapshot(SESSION)).submissions.at(-1)! + expect(pointer.options).toBeUndefined() + expect(store.getRecord(SESSION)?.options?.model).toBe('opus') + for (const [index, captured] of RESUMED_BY_MESSAGE.filter( + ({ at }) => at > 4_765 && at <= 10_960 + ).entries()) { + frame(first, { uuid: `native-result-${index}`, ...captured.frame }) + } + await eventually(async () => + expect(await savedRows()).toEqual([ + expect.objectContaining({ state: 'completed', tokens: 15_971 }) + ]) + ) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + const consumer = { + runId, + mailboxHandle, + consumerGeneration: db.getDispatchContextById(dispatch.id)!.consumer_generation + } + const checked = db.getOrCreateMailboxDelivery(consumer)! + expect(checked.messages.map(({ id }) => id)).toEqual([mail.id]) + expect(db.getMessageById(mail.id)?.read).toBe(0) + db.acknowledgeMailboxDelivery({ ...consumer, deliveryId: checked.delivery.id }) + expect(db.getMessageById(mail.id)?.read).toBe(1) + const done = db.insertMessage({ + runId, + from: handle, + to: 'coordinator', + type: 'worker_done', + subject: 'Done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) + }) + expect(reconcileLifecycleMessage(db, done).action).toBe('completed') + expect(db.getTask(task.id)?.status).toBe('completed') + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + permissionMode: 'auto', + now: clock + }) + const followupTask = db.createTask({ runId, spec: 'Continue on the same worker.' }) + const followup = db.createDispatchContext({ + taskId: followupTask.id, + assigneeHandle: handle, + processIncarnation: `structured:${SESSION}`, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(structuredWorkerOwesWork(db, store.getRecord(SESSION)!)).toBe(true) + + const nextBody = hostTestMessage('Reply on my captured choice.') + const options = { model: 'opus' } + const close = first.close + vi.spyOn(first, 'close').mockImplementationOnce(async () => { + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'haiku' }, + now: clock + }) + return close() + }) + const push = claude.connections.push.bind(claude.connections) + vi.spyOn(claude.connections, 'push').mockImplementation((connection) => { + const initialize = connection.initializationResult + connection.initializationResult = async () => { + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'reopened-root', + model: 'claude-opus-4-6', + permissionMode: 'default' + }) + return initialize() + } + return push(connection) + }) + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: nextBody, options }), + body: nextBody, + options + }) + await eventually(() => expect(claude.connections).toHaveLength(2)) + const second = claude.connections[1]! + expect(first.closeCount).toBe(1) + expect(second.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + expect(store.getRecord(SESSION)?.options?.model).toBe('haiku') + expect(store.getRecord(SESSION)?.providerHandleChain).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handle: expect.objectContaining({ transport: 'claude-sdk', nativeId: PROVIDER_SESSION_ID }) + }) + ]) + ) + await eventually(() => expect(second.sent).toHaveLength(1)) + frame(second, { ...second.sent[0]!, uuid: 'new-choice-echo' }) + frame(second, { + type: 'assistant', + uuid: 'reply-1', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'First reply.' }] } + }) + frame(second, { type: 'result', subtype: 'success', uuid: 'result-1', result: 'First reply.' }) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + const again = hostTestMessage('Reply again.') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: again, options }), + body: again, + options + }) + await eventually(() => expect(second.sent).toHaveLength(2)) + frame(second, { ...second.sent[1]!, uuid: 'second-echo' }) + frame(second, { + type: 'assistant', + uuid: 'reply-2', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Second reply.' }] } + }) + frame(second, { type: 'result', subtype: 'success', uuid: 'result-2', result: 'Second reply.' }) + await host.flushStreamedEvents(SESSION) + const transcript = JSON.stringify((await host.journalSnapshot(SESSION)).items) + expect(transcript).toContain('First reply.') + expect(transcript).toContain('Second reply.') + expect(claude.connections).toHaveLength(2) + expect(db.getDispatchContextById(followup.id)?.status).toBe('dispatched') + expect(structuredWorkerOwesWork(db, store.getRecord(SESSION)!)).toBe(true) +}) + it("keeps a background helper's row working past its spawn result and its turn's end, until its own end", async () => { const connection = claude.connections[0]! await sendAndEcho(connection, 'Run it in the background.') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 36ae49b9122c..616d118b65e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -15,23 +15,25 @@ // settles it first (`journal-unsent-send-hold.ts`). import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { - acceptedNoLaterThan, oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure, + recordStructuredAgentSessionDeliveryStartFailure, refusedStructuredAgentSessionStart, startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' @@ -50,6 +52,7 @@ import { waitsForCodexTurnOptions } from './structured-agent-session-message-opt import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { rejectBlockedStructuredAgentSessionTransition } from './structured-agent-session-outgrown-child' export type StructuredAgentSessionDeliveryLoopDeps = { deliveryOrder: StructuredAgentSessionDeliveryOrder @@ -62,7 +65,10 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** Claims the start for the queued head inside the ordered preparation. */ startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim /** Stops a child the chat outgrew (a new login or permission mode); inside `serialize`. */ - retireOutgrownChild: (sessionId: string) => Promise + retireOutgrownChild: ( + sessionId: string, + next: AgentJournalSubmission + ) => Promise readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity @@ -92,6 +98,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Prepared = | 'stop' + | 'continue' | { start: StructuredAgentSessionStartClaim; acceptedBy: AgentJournalCursor } | { ok: true @@ -153,6 +160,9 @@ export class StructuredAgentSessionDeliveryLoop { if (prepared === 'stop') { return } + if (prepared === 'continue') { + continue + } if ('start' in prepared) { // Outside the serialize: sends are accepted while the agent starts. const started = await prepared.start.outcome @@ -171,7 +181,12 @@ export class StructuredAgentSessionDeliveryLoop { // An unproven close fails only what was queued when the start was asked for: a later // ask runs the close again, so its verdict no longer holds for what came after. await this.deps.serialize(sessionId, () => - this.recordFailure(sessionId, failure, prepared.acceptedBy) + recordStructuredAgentSessionDeliveryStartFailure( + this.deps, + sessionId, + failure, + prepared.acceptedBy + ) ) continue } @@ -180,7 +195,7 @@ export class StructuredAgentSessionDeliveryLoop { } await prepared.before const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited, prepared.readiness !== null) + this.handOver(sessionId, prepared.awaited) ) if (handed === 'stop') { return @@ -248,7 +263,9 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } - await this.deps.retireOutgrownChild(sessionId) + if (await rejectBlockedStructuredAgentSessionTransition(this.deps, sessionId, oldest)) { + return 'continue' + } const child = this.deps.sessions.get(sessionId)?.child if (!child || child.close) { return { @@ -267,8 +284,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity, - waited: boolean + awaited: StructuredAgentSessionProviderChildIdentity ): Promise<'continue' | 'stop' | StructuredAgentSessionPendingHandover> { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -306,12 +322,12 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } - if (waited) { - // Re-check launch permission only after proving the awaited child still exists. - await this.deps.retireOutgrownChild(sessionId) - if (session.child !== awaitedChild) { - return 'continue' - } + // Re-derive for the actual next input after proving the awaited child still exists. + if ( + (await rejectBlockedStructuredAgentSessionTransition(this.deps, sessionId, next)) || + session.child !== awaitedChild + ) { + return 'continue' } if ( waitsForCodexTurnOptions(this.deps.record(sessionId), session.journal, next, awaitedChild) @@ -344,31 +360,10 @@ export class StructuredAgentSessionDeliveryLoop { } private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { - await this.recordFailure(sessionId, failure) + await recordStructuredAgentSessionDeliveryStartFailure(this.deps, sessionId, failure) return this.stop(sessionId) } - private async recordFailure( - sessionId: string, - failure: StartFailure, - acceptedBy?: AgentJournalCursor - ): Promise { - const session = this.deps.sessions.get(sessionId) - if (session) { - await recordStructuredAgentSessionStartFailure( - { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - { - startKey: failure.startKey, - ...structuredAgentSessionStartFailure( - failure.cause, - this.deps.failureTextContext(sessionId) - ) - }, - acceptedBy && acceptedNoLaterThan(session.journal, acceptedBy) - ) - } - } - /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 9f75648ba399..b88d20209978 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -88,12 +88,20 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, startProviderChild: input.startProviderChild, - retireOutgrownChild: async (sessionId) => { + retireOutgrownChild: async (sessionId, next) => { // A send waiting on a person's Stop is not handed over yet; the step after the Stop decides. const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) - await retireOutgrownStructuredAgentSessionChild(sessionId, session, { + return retireOutgrownStructuredAgentSessionChild(sessionId, session, { + next, + requiresModelTransitionRestart: (submission) => + session?.child?.phase === 'ready' && + deps.adapter.requiresModelTransitionRestart?.({ + sessionId, + fence: session.child.fence, + executionOptions: submission.options + }) === true, work: { currentWork: () => hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 411106d73c44..5a3b1fbb6ee0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -86,6 +86,8 @@ export function sendStructuredAgentSessionTurn( * launch's first prompt. `userSend` is always one; another agent's message carries `from`. */ personsMessage?: true beforeRun?: () => void + /** Host-local: keep the running process's choices instead of capturing saved ones. */ + keepCurrentOptions?: true }, arrival?: Parameters[2] ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 41574c0ac767..4c56ffdafa4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -1,7 +1,5 @@ -// The mode a start launches with: Claude's Approve for me drops to Ask only on a model the saved -// catalog lists without it; a catalog that can't say keeps the saved choice. - import { describe, expect, it, vi } from 'vitest' +import { AGENT_CHAT_PERMISSION_MODES } from '../../../shared/agent-chat-permission-mode' 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' @@ -45,75 +43,53 @@ function launchPermission( stop: vi.fn(), providerStarted: vi.fn() } - return { - read, - result: structuredAgentSessionLaunchPermission( - { agents: claudeAndCodexDeclared(), modelCatalog }, - record(provider, options) - ) - } + const deps = { agents: claudeAndCodexDeclared(), modelCatalog } + return { read, result: structuredAgentSessionLaunchPermission(deps, 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.each(AGENT_CHAT_PERMISSION_MODES)( + 'launches saved Claude %s without consulting the catalog', + async (permissionMode) => { + const launch = launchPermission( + 'claude', + { permissionMode, model: 'haiku' }, + listing([{ id: 'haiku' }]) + ) + await expect(launch.result).resolves.toBe(permissionMode) + expect(launch.read).not.toHaveBeenCalled() + } + ) - it('asks on a model listed without auto, or a chat with no model whose listed default lacks it', async () => { - const rows = listing([ - { id: 'sonnet', isDefault: true }, - { id: 'opus', supportsAutoMode: true }, - { id: 'haiku' } - ]) - const savedOptions: Record[] = [{ model: 'haiku' }, {}] + // #30032: a catalog saved under another account home read as none, and the chat relaunched asking. + it('keeps saved auto with no selected model, unlisted models, or an unavailable catalog', async () => { + const savedOptions: Record[] = [{}, { model: 'claude-opus-5-5[1m]' }] for (const options of savedOptions) { - await expect( - launchPermission('claude', { permissionMode: 'auto', ...options }, rows).result - ).resolves.toBe('ask') - } - // A CLI before 2.1.72 does not report model auto support; the auto value arrived in 2.1.71. - await expect( - launchPermission( + const launch = launchPermission( 'claude', - { permissionMode: 'auto', model: 'opus' }, - listing([{ id: 'opus' }, { id: 'sonnet' }]) - ).result - ).resolves.toBe('ask') + { permissionMode: 'auto', ...options }, + new Error('catalog unavailable') + ) + await expect(launch.result).resolves.toBe('auto') + expect(launch.read).not.toHaveBeenCalled() + } + await expect(launchPermission('codex', { permissionMode: 'auto' }).result).resolves.toBe('auto') }) - // #30032: a catalog saved under another account home read as none, and the chat relaunched asking. - it('keeps the saved auto when the catalog cannot say whether the model runs it', async () => { - for (const answer of [ - { origin: 'unknown' } as const, - new Error('catalog unavailable'), - listing([{ id: 'sonnet', supportsAutoMode: true }]) - ]) { - await expect( - launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result - ).resolves.toBe('auto') - } + it('asks for an absent or invalid saved mode, including modes the provider does not offer', async () => { + await expect(launchPermission('claude', undefined).result).resolves.toBe('ask') + await expect( + launchPermission('claude', { permissionMode: 'bypassPermissions' }).result + ).resolves.toBe('ask') + await expect( + launchPermission('codex', { permissionMode: 'accept-edits' }).result + ).resolves.toBe('ask') }) - it('has none for an agent with no modes', async () => { + it('has none for an unregistered agent or one with no modes', async () => { + await expect(launchPermission('grok', { permissionMode: 'full-access' }).result).resolves.toBe( + null + ) await expect(launchPermission('pi', { permissionMode: 'full-access' }).result).resolves.toBe( null ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index b752ea127e4b..54296aac9ebf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -3,48 +3,19 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentChatPermissionModes, - chatModelListedWithoutAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, + savedChatPermissionMode, type AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -/** Saved catalog only, so a start never waits on a listing; for the chat's own folder, as its - * options read names its default. A missing catalog keeps the saved choice: Claude itself starts - * a model that can't run `auto` in its default mode. */ -async function listedWithoutAutoMode( - catalog: StructuredAgentSessionHostDeps['modelCatalog'], - record: AgentSessionRecord -): Promise { - 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' && - chatModelListedWithoutAutoMode(listing.models, record.options?.model) - ) -} - /** Null for an agent whose chats offer no modes, or one this runtime did not register. */ export async function structuredAgentSessionLaunchPermission( - deps: Pick, + deps: Pick, record: AgentSessionRecord ): Promise { const support = agentChatPermissionModes(record.provider) if (!support || !deps.agents.definition(record.provider)) { return null } - const intent = record.permissionMode - const listedWithoutAuto = - chatPermissionModeNeedsModelSupport(support, intent) && - (await listedWithoutAutoMode(deps.modelCatalog, record)) - return effectiveChatPermissionMode(support, intent, listedWithoutAuto) + return savedChatPermissionMode(support, record.permissionMode) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index b13e3aa3853a..b0eec4eebe92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -141,6 +141,7 @@ export function sendPlan( /** A person's message the host sends for them; `userSend` is always one. */ personsMessage?: true beforeRun?: () => void + keepCurrentOptions?: true } ): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -166,6 +167,7 @@ export function sendPlan( ...(source ? { source } : {}), clientMessageId, ...(params.options ? { options: params.options } : {}), + ...(params.keepCurrentOptions ? { keepCurrentOptions: true } : {}), // Body-only, so the reducer's echo aliasing never sees control fields or the sender. payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 6369245f5d41..1f7b14ef38c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -18,10 +18,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 { AgentChatPermissionMode } 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, @@ -133,6 +130,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, newestTurn: (): AgentJournalTurnLifecycle | null => input.newestTurnEndedAt === undefined ? null @@ -284,82 +282,6 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { ).not.toHaveBeenCalled() }) - // The check reads the same mode a start launches with, so each start ends the restarts. - describe('against the mode a start launches with', () => { - // The saved catalog the probe keeps from `claude` 2.1.280: the default row folded into the - // row it resolves to, every row but haiku listed as running auto. - const listed = [ - { id: 'opus[1m]', isDefault: true, supportsAutoMode: true }, - { id: 'sonnet', isDefault: false, supportsAutoMode: true }, - { id: 'haiku', isDefault: false } - ] - const catalog = (rows: typeof listed) => ({ - 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, 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: AgentChatPermissionMode, - current: () => Promise - ) => - (await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current)) - .stopAgent.mock.calls.length > 0 - - it('restarts Full access or Accept edits into Ask on a model listed without auto', async () => { - const autoPick = launch({ model: 'haiku', permissionMode: 'auto' }) - 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('restarts into auto once the model is no longer listed without it', async () => { - const onHaiku = await launch({ model: 'haiku', permissionMode: 'auto' })() - const onSonnet = launch({ model: 'sonnet', permissionMode: 'auto' }) - expect(await stoppedAfterLaunch(onHaiku!, onSonnet)).toBe(true) - expect(await stoppedAfterLaunch((await onSonnet())!, onSonnet)).toBe(false) - }) - - it('never restarts a child it just started, from an alias or a resolved id', async () => { - const records: Record[] = [ - { 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, true] - 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) - } - }) - }) - 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() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 8ae3c72f662c..33cb67d8a489 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,9 +1,12 @@ -// A child the chat outgrew (signed out, or launched with another permission mode) is replaced before -// the next send: agents read their login and permission mode only when they start. +// Replace outgrown launch construction before user input; work the process owns keeps its child. -import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + readWholeAgentSessionFailureFact +} from '../../../shared/agent-session-failure' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -13,10 +16,47 @@ import { } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' +import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' + +export async function rejectBlockedStructuredAgentSessionTransition( + deps: { + retireOutgrownChild: ( + sessionId: string, + next: AgentJournalSubmission + ) => Promise + sessions: ReadonlyMap> + conversationFence: (sessionId: string) => number + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext + }, + sessionId: string, + next: AgentJournalSubmission +): Promise { + const refusal = await deps.retireOutgrownChild(sessionId, next) + if (!refusal) { + return false + } + await deps.sessions.get(sessionId)?.journal.resolveDispatch({ + clientMessageId: next.clientMessageId, + state: 'rejected', + fence: deps.conversationFence(sessionId), + ...agentSessionFailureWords(agentSessionFailureFact('restartFailed', { refusal }), { + ...deps.failureTextContext(sessionId), + surface: 'rejection' + }) + }) + return true +} /** What the check reads of a conversation; every read skips building a snapshot. */ type OutgrownChildReading = Pick & { - journal: Pick & { + journal: Pick< + AgentSessionJournal, + 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn' | 'itemBody' + > & { submissions(): readonly Pick[] } } @@ -95,19 +135,45 @@ export async function retireOutgrownStructuredAgentSessionChild( } /** The mode a start of the chat would launch with now. */ launchPermission?: () => Promise + next?: AgentJournalSubmission + requiresModelTransitionRestart?: (next: AgentJournalSubmission) => boolean stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } -): Promise { +): Promise { + if (!session) { + return + } + const body = + deps.next && session.journal.itemBody(agentJournalSubmissionKey(deps.next.clientMessageId)) + if (body?.kind === 'message' && body.from?.orchestration?.message === 'mail-notice') { + return + } + const otherReason = + structuredAgentSessionChildReportedSignedOut(session) || + (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) + const modelTransition = deps.next && deps.requiresModelTransitionRestart?.(deps.next) === true + if (!otherReason && !modelTransition) { + return + } + const wakeDebt = owesWakeUp(session.journal, deps.work.childWork()) if ( - !session || - !( - structuredAgentSessionChildReportedSignedOut(session) || - (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) + structuredAgentSessionChildHasOpenWork( + modelTransition && !otherReason ? { ...deps.work, hasOpenDispatch: () => false } : deps.work ) || - structuredAgentSessionChildHasOpenWork(deps.work) || - owesWakeUp(session.journal, deps.work.childWork()) + wakeDebt ) { + return modelTransition + ? { + code: 'agent_session_operation_invalid', + details: { + reason: + deps.work.childWorkOpen() || wakeDebt ? 'backgroundTasksRunning' : 'messagesUnsettled' + } + } + : undefined + } + if (!session.child || session.child.phase === 'starting' || session.child.close) { return } await deps.stopAgent(sessionId).catch((error: unknown) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 4c7e0d271cdb..a83fcdb60cba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -17,6 +17,31 @@ import type { } from '../../../shared/agent-session-journal-types' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' + +export async function recordStructuredAgentSessionDeliveryStartFailure( + deps: { + sessions: ReadonlyMap> + conversationFence: (sessionId: string) => number + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext + }, + sessionId: string, + failure: NonNullable>, + acceptedBy?: AgentJournalCursor +): Promise { + const session = deps.sessions.get(sessionId) + if (session) { + await recordStructuredAgentSessionStartFailure( + { journal: session.journal, fence: deps.conversationFence(sessionId) }, + { + startKey: failure.startKey, + ...structuredAgentSessionStartFailure(failure.cause, deps.failureTextContext(sessionId)) + }, + acceptedBy && acceptedNoLaterThan(session.journal, acceptedBy) + ) + } +} /** A queued message whose child died while it waited inherits that child's failure. */ export function startThatFailedWhileQueued( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index e6bf58e07309..aa15dd209c74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -115,6 +115,7 @@ export async function performSend( source?: AgentSessionMessageSource /** The send's `options` record, read once here into the choices the message keeps. */ options?: Readonly> + keepCurrentOptions?: true } ): Promise> { const existing = ctx.journal @@ -132,13 +133,16 @@ export async function performSend( value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } - const captured = captureStructuredExecutionOptions(ctx, input.options) + const { keepCurrentOptions, ...submission } = input + const captured = keepCurrentOptions + ? { options: undefined } + : captureStructuredExecutionOptions(ctx, input.options) if ('refusal' in captured) { return { ok: false, refusal: captured.refusal } } try { await ctx.journal.appendSubmission( - { ...input, options: captured.options, fence: ctx.fence, handoverRecorded: true }, + { ...submission, options: captured.options, fence: ctx.fence, handoverRecorded: true }, undefined, ctx.operationReceipt ) diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index c9f30abb2e04..7a66fbd7f975 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -43,6 +43,8 @@ export type StructuredSessionTurn = { delivery: AgentTurnDelivery /** Host-local admission for an automated send. */ beforeRun?: () => void + /** Host-local: a background notice carries no changed user choices. */ + keepCurrentOptions?: true } export type StructuredSessionTurnSend = { @@ -138,7 +140,7 @@ async function sendStructuredSessionTurn( }) const result = await send.host.send( { callerKey: send.callerKey }, - turn.beforeRun ? { ...message, beforeRun: turn.beforeRun } : message + { ...message, beforeRun: turn.beforeRun, keepCurrentOptions: turn.keepCurrentOptions } ) if (!result.ok) { return { kind: 'refused', refusal: result.refusal } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 78ecf621c22e..d8ea2a36588a 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -128,6 +128,7 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo body: input.body, // A person's plain send: every agent takes it the way it takes their own mid-turn message. delivery: 'now', + keepCurrentOptions: true, operationId: input.operationId, expectedRuntimeFence: input.expectedRuntimeFence, beforeRun: () => { diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index 4a8abce28352..60ec96884e9c 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -2,12 +2,9 @@ import { describe, expect, it } from 'vitest' import { AGENT_CHAT_PERMISSION_MODES, agentChatPermissionModes, - chatModelSupportsAutoMode, - chatPermissionModeNeedsModelSupport, - effectiveChatPermissionMode, loadedChatPermissionSetting, newChatPermissionMode, - offeredChatPermissionModes + savedChatPermissionMode } from './agent-chat-permission-mode' import { getDefaultSettings } from './constants' import { applyAgentPermissionMode } from './tui-agent-permissions' @@ -30,7 +27,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') @@ -42,42 +38,11 @@ describe('agent chat permission modes', () => { }) describe('the mode a chat runs', () => { - it('runs Claude auto unless its model is listed without it', () => { - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', false)).toBe('auto') - expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', true)).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', true)).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' - ]) + 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') }) }) @@ -160,23 +125,4 @@ describe('the new-chat setting as loaded', () => { it.each(['future', null, 42, 'bypass'])('reads an unknown saved %s as Ask', (value) => { expect(loadedChatPermissionSetting(value, getDefaultSettings('/tmp'), 'darwin')).toBe('ask') }) - - // 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 - ) - }) }) diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index eac37883d62f..547f0862490a 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -38,13 +38,10 @@ export function isAgentChatPermissionMode(value: unknown): value is AgentChatPer /** The modes one agent's chats offer. */ export type AgentChatPermissionModeSupport = { modes: readonly AgentChatPermissionMode[] - /** `auto` doesn't run on a model the agent's listing shows without it. */ - autoNeedsModelSupport?: true } const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { - modes: AGENT_CHAT_PERMISSION_MODES, - autoNeedsModelSupport: true + modes: AGENT_CHAT_PERMISSION_MODES } const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { @@ -84,67 +81,6 @@ export function savedChatPermissionMode( return support.modes.find((mode) => mode === intent) ?? 'ask' } -type ListedChatModel = { id: string; isDefault?: boolean; supportsAutoMode?: boolean } - -/** The chat's model's listed row, or the listed CLI default when it names none. */ -function listedChatModelRow( - models: readonly ListedChatModel[], - model: string | null | undefined -): ListedChatModel | undefined { - return models.find((entry) => - model === undefined || model === null || model === 'default' - ? entry.isDefault === true - : entry.id === model - ) -} - -/** Whether the menu offers `auto`: the chat's model is listed as running it. */ -export function chatModelSupportsAutoMode( - models: readonly ListedChatModel[], - model: string | null | undefined -): boolean { - return listedChatModelRow(models, model)?.supportsAutoMode === true -} - -/** Whether the listing shows the chat's model can't run `auto`. Claude marks only supporting models. - * A CLI before 2.1.72 does not report model auto support; an unlisted model is not evidence. */ -export function chatModelListedWithoutAutoMode( - models: readonly ListedChatModel[], - model: string | null | undefined -): boolean { - const row = listedChatModelRow(models, model) - return row !== undefined && row.supportsAutoMode !== true -} - -/** Whether the mode a chat runs depends on its model's listed `auto` support. */ -export function chatPermissionModeNeedsModelSupport( - support: AgentChatPermissionModeSupport, - intent: unknown -): boolean { - return ( - support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto' - ) -} - -/** Saved choice, with `auto` on a model listed without it read as Ask; never written back. */ -export function effectiveChatPermissionMode( - support: AgentChatPermissionModeSupport, - intent: unknown, - listedWithoutAuto: boolean -): AgentChatPermissionMode { - const mode = savedChatPermissionMode(support, intent) - return mode === 'auto' && support.autoNeedsModelSupport && listedWithoutAuto ? 'ask' : mode -} - -export function offeredChatPermissionModes( - support: AgentChatPermissionModeSupport, - autoSupported: boolean -): readonly AgentChatPermissionMode[] { - return support.autoNeedsModelSupport && !autoSupported - ? support.modes.filter((mode) => mode !== 'auto') - : support.modes -} - /** The host's new-chat default as an agent's chat starts with it; null for an agent with none. */ export function newChatPermissionMode( agent: string, diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 650fecf5223c..f7cc718e9ae9 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -139,6 +139,10 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { return installationFailure } const failed = say(verb, agent(say, context)) + const blocked = commandRefusedByReason(say, context, fact) + if (blocked) { + return joinSentences([failed, blocked, ...startRetry(say, context)]) + } if (fact.argumentProblem) { const problemCopy = { unsupportedOption: 'argumentsUnsupportedOption', diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index 235a2735d5d4..0e7f25ba6994 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -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, @@ -77,52 +76,23 @@ 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) + it('offers all Claude modes regardless of catalog support, and shows the host mode as stated', () => { + for (const autoMode of [true, false, undefined]) { + const descriptor = permission(live('claude', { permissionMode: 'ask' }, autoMode)) + expect( + descriptor?.kind.type === 'select' && descriptor.kind.choices.map((c) => c.value) + ).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) } - 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. expect(permission(live('claude', { permissionMode: 'auto' }, false))).toMatchObject({ kind: { currentValue: 'auto' } }) - expect(offered(false, 'auto')).toEqual(['ask', 'accept-edits', 'auto', 'full-access']) - }) - - // 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", () => { diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 53f3e3e7cb49..ffc7a3fcf01b 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -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,20 +51,7 @@ function agentDescriptions(agent: string): Partial ({ + choices: support.modes.map((value) => ({ value, label: MODE_COPY[value].label, description: agentDescriptions(state.record.agent)[value] ?? MODE_COPY[value].description From 7293bedef005c67e47b6757700ed6f041fba4dd2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:44:03 -0700 Subject: [PATCH 06/16] Match outgrown child fixture to journal null contract --- .../structured-agent-session-outgrown-child.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 1f7b14ef38c6..f38ffcb40d30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -130,7 +130,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, + itemBody: (itemId: string) => items.find((item) => item.itemId === itemId)?.body ?? null, newestTurn: (): AgentJournalTurnLifecycle | null => input.newestTurnEndedAt === undefined ? null From e0cbb58f103d6643418843fa44c72985c71c5419 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:22:06 -0700 Subject: [PATCH 07/16] fix(native-chat): preserve admitted input and current mail choices --- ...ured-agent-options.permission-mode.test.ts | 6 +- ...t-session-claude-opening-send-hold.test.ts | 139 ++++++++++++++++-- .../structured-agent-session-host-delivery.ts | 18 ++- ...tructured-agent-session-message-options.ts | 2 +- ...t-session-options.permission-mode.test.tsx | 19 ++- ...claude-auto-mode-host-catalog.unit.test.ts | 19 ++- 6 files changed, 180 insertions(+), 23 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 821c0f8c58ef..2bc4b0416860 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -244,7 +244,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, @@ -252,10 +252,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() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index a968e2e4c6f8..2fbb1877ab0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -38,6 +38,7 @@ import { claudeAndCodexDeclared } from './structured-agent-session-adapter-route import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' const CALLER = { callerKey: 'client-1' } const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] @@ -47,20 +48,23 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let claude: ReturnType +let claudeFrames: { replayUuid: null; initProof?: 'none' } beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-opening-send-')) resetHostTestOperationIds() const log = recordingStructuredAgentSessionLogger() - claude = fakeClaude({ - replayUuid: null, - routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } - }) + claudeFrames = { replayUuid: null } + claude = fakeClaude( + Object.assign(claudeFrames, { + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + ) const lifecycle: Promise[] = [] adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ + resolveLaunch: async ({ permissionMode }) => ({ pathToClaudeCodeExecutable: 'claude', - options: {}, + options: claudeStructuredPermissionOptions(permissionMode ?? 'ask'), cwd: root, claudeConfigDir: join(root, 'claude-home'), providerSessionId: PROVIDER_SESSION_ID, @@ -94,6 +98,7 @@ beforeEach(async () => { const params = hostTestAttachParams(null, { provider: 'claude', agent: 'claude', + options: { model: 'haiku', permissionMode: 'auto' }, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) @@ -111,7 +116,7 @@ afterEach(async () => { const eventually = (assertion: () => T | Promise) => vi.waitFor(assertion, { timeout: 10_000 }) -async function send(text: string): Promise { +async function send(text: string, options?: Record): Promise { const body = hostTestMessage(text) const sent = await host.send(CALLER, { envelope: { @@ -121,10 +126,11 @@ async function send(text: string): Promise { payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: SESSION, - fields: { body } + fields: { body, options } }) }, - body + body, + options }) if (!sent.ok) { throw new Error(JSON.stringify(sent.refusal)) @@ -173,6 +179,121 @@ it('reads a send as accepted across its echo, never in doubt, so a worker start expect(agentSessionSendSubmission((await answer)?.value)?.dispatchState).toBe('accepted') }) +it('protects an admitted unechoed input from a model transition, then reopens for a fresh choice', async () => { + claudeFrames.initProof = 'none' + const connection = claude.connections[0]! + expect(connection.launch.options).toMatchObject({ model: 'haiku', permissionMode: 'auto' }) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'haiku-root', + model: 'claude-haiku-4-5', + permissionMode: 'default' + }) + const first = await send('ADMITTED prompt') + await eventually(() => expect(written(connection, 'ADMITTED prompt')).toBeDefined()) + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + const admitted = (await snapshot()).submissions.find((entry) => entry.clientMessageId === first)! + expect(admitted).toMatchObject({ dispatchState: 'pending', handedOverAt: expect.any(Number) }) + expect(host.currentWork(SESSION)?.owesSend(admitted)).toBe(true) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + expect(adapter.holdsDispatch(SESSION)).toBe(false) + + const blocked = await send('BLOCKED transition', { model: 'opus' }) + await eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(connection.closeCount).toBe(0) + expect(connection.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + const submissions = (await snapshot()).submissions + expect(submissions.find((entry) => entry.clientMessageId === blocked)).toMatchObject({ + dispatchState: 'rejected', + rejection: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'messagesUnsettled' } } + } + }) + expect(submissions.find((entry) => entry.clientMessageId === first)?.dispatchState).toBe( + 'pending' + ) + expect(host.currentWork(SESSION)?.owesSend(admitted)).toBe(true) + expect(written(connection, 'BLOCKED transition')).toBeUndefined() + + async function reply(on: FakeConnection, prompt: string, text: string, id: string) { + const input = written(on, prompt)! + frame(on, { ...input, isReplay: true, parent_tool_use_id: null }) + frame(on, { + type: 'assistant', + uuid: `assistant-${id}`, + parent_tool_use_id: null, + message: { id: `msg-${id}`, role: 'assistant', content: [{ type: 'text', text }] }, + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { + type: 'result', + subtype: 'success', + uuid: `result-${id}`, + is_error: false, + terminal_reason: 'completed', + result: text, + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + await eventually(async () => { + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === id) + ).toMatchObject({ dispatchState: 'accepted' }) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + }) + } + await reply(connection, 'ADMITTED prompt', 'Original result retained.', first) + expect((await drawn()).map(({ text }) => text)).toContain('Original result retained.') + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === blocked)?.dispatchState + ).toBe('rejected') + + const fresh = await send('FRESH transition', { model: 'opus' }) + await eventually(() => { + expect(claude.connections).toHaveLength(2) + expect(written(claude.connections[1]!, 'FRESH transition')).toBeDefined() + }) + const resumed = claude.connections[1]! + expect(connection.closeCount).toBe(1) + expect(resumed.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + expect(store.getRecord(SESSION)?.providerHandleChain).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handle: expect.objectContaining({ nativeId: PROVIDER_SESSION_ID }) + }) + ]) + ) + frame(resumed, { + type: 'system', + subtype: 'init', + uuid: 'opus-root', + model: 'claude-opus-4-6', + permissionMode: 'default' + }) + await reply(resumed, 'FRESH transition', 'First new-model reply.', fresh) + const repeated = await send('REPEATED choice', { model: 'opus' }) + await eventually(() => expect(written(resumed, 'REPEATED choice')).toBeDefined()) + await reply(resumed, 'REPEATED choice', 'Second new-model reply.', repeated) + expect(claude.connections).toHaveLength(2) + expect(resumed.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + expect(claude.connections.some((child) => written(child, 'BLOCKED transition'))).toBe(false) + expect((await drawn()).map(({ text }) => text)).toEqual( + expect.arrayContaining([ + 'Original result retained.', + 'First new-model reply.', + 'Second new-model reply.' + ]) + ) +}) + it('holds a follow-up until the echo opens the turn, then folds it in, drawn inside that turn', async () => { const connection = claude.connections[0]! const first = await send('FIRST prompt') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index b88d20209978..cf43e8d72d82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -111,7 +111,23 @@ export function createStructuredAgentSessionConversationDelivery(input: { const record = deps.store.getRecord(sessionId) return record !== null && deps.hasOpenDispatch?.(record) === true }, - providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true + providerHoldsDispatch: () => { + if (deps.adapter.holdsDispatch?.(sessionId) === true) { + return true + } + const work = hostStructuredAgentSessionCurrentWork( + { store: deps.store, sessions }, + sessionId + ) + return ( + session?.journal + .submissions() + .some( + (submission) => + !isQueuedAgentJournalSubmission(submission) && work?.owesSend(submission) + ) === true + ) + } }, launchPermission: async () => { const record = deps.store.getRecord(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 63dc47f5bea6..798269a928e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -27,7 +27,7 @@ export function waitsForCodexTurnOptions( next: AgentJournalSubmission, child: StructuredAgentSessionProviderChild ): boolean { - if (record?.provider !== 'codex') { + if (record?.provider !== 'codex' || next.options === undefined) { return false } const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index da6e34ca58b9..a695c43a6d7b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -163,7 +163,10 @@ describe('a permission pick on this device', () => { ) const modelWrite = Promise.withResolvers<{ options: Record }>() const modeWrite = Promise.withResolvers<{ options: Record }>() - 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[]) => { @@ -179,11 +182,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 } })) diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 365b2be067e3..086b4e1433df 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -198,17 +198,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((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() }) From 1dab215fdc174f5eb813ae6072f796a59f11284b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:32:16 -0700 Subject: [PATCH 08/16] perf(native-chat): derive wake debt and held sends once per retirement decision The retirement check derived wake debt (a child-work projection and a scan for the newest turn) before asking whether open work had already decided the outcome, and its admitted-input fallback rebuilt the provider's held-send set once per unanswered submission, O(S x W) per decision. Open work is now read first and wake debt only where it picks the outcome or the refusal reason. Current work gains one batch query that reads the held set and the handover set once, sharing the single holds rule with the per-send reader. Outcomes and refusal reasons are unchanged. Adds N/10N operation-count contracts on the real Claude host rig: two retirement checks per ordinary send, each one pass over history, with a control that the second sees a sign-out reported between them; no wake debt mid-turn; one held-set read per refusal in a queued model-change burst. --- ...t-session-claude-opening-send-hold.test.ts | 215 +++++++++++++++++- .../structured-agent-session-current-work.ts | 21 +- .../structured-agent-session-host-delivery.ts | 19 +- ...tured-agent-session-outgrown-child.test.ts | 48 ++++ ...structured-agent-session-outgrown-child.ts | 22 +- 5 files changed, 296 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 2fbb1877ab0f..266b6443f1b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -6,7 +6,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, beforeEach, describe, expect, it, onTestFinished, 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' @@ -25,6 +25,8 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' +import type * as OutgrownChild from './structured-agent-session-outgrown-child' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -39,6 +41,18 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/struct import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +vi.mock('./structured-agent-session-outgrown-child', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + retireOutgrownStructuredAgentSessionChild: vi.fn( + actual.retireOutgrownStructuredAgentSessionChild + ) + } +}) const CALLER = { callerKey: 'client-1' } const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] @@ -579,3 +593,202 @@ 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()) }) + +/** Rows an older host wrote as it sent them, at the live child's fence: never answered, and the + * CLI holding none of them, unless each was accepted or refused as not signed in. */ +async function olderHostSends(count: number, answer?: 'accepted' | 'notSignedIn'): Promise { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const fence = store.getRecord(SESSION)!.lease.runtimeFence + for (let index = 0; index < count; index += 1) { + const clientMessageId = `older-${index}` + const body = hostTestMessage(clientMessageId) + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: clientMessageId, + body, + fence + }) + if (answer === 'accepted') { + await journal.resolveDispatch({ + clientMessageId, + fence, + state: 'accepted', + providerIdentity: null + }) + } else if (answer === 'notSignedIn') { + await journal.resolveDispatch({ + clientMessageId, + fence, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact(answer), { surface: 'rejection' }) + }) + } + } +} + +/** What the delivery loop's retirement checks read: the journal's history, wake debt (its only + * `newestTurn` reader there), and what the CLI holds, built by the adapter itself on each read. */ +function countRetirementReads() { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const counts = { + checks: 0, + linkageVisits: 0, + submissionsCopied: 0, + newestTurn: 0, + heldSets: 0, + heldVisits: 0, + rejections: 0, + afterCheck: async (_check: number): Promise => undefined + } + let inside = false + const retire = vi.mocked(retireOutgrownStructuredAgentSessionChild) + const actual = retire.getMockImplementation()! + onTestFinished(() => void retire.mockImplementation(actual)) + retire.mockImplementation(async (...args) => { + counts.checks += 1 + inside = true + try { + return await actual(...args) + } finally { + inside = false + await counts.afterCheck(counts.checks) + } + }) + const { visitItemsWithLinkage, submissions, newestTurn } = journal + journal.visitItemsWithLinkage = (visit) => + visitItemsWithLinkage((...item) => { + counts.linkageVisits += inside ? 1 : 0 + visit(...item) + }) + journal.submissions = () => { + const all = submissions() + counts.submissionsCopied += inside ? all.length : 0 + return all + } + journal.newestTurn = () => { + counts.newestTurn += inside ? 1 : 0 + return newestTurn() + } + const resolveDispatch = journal.resolveDispatch.bind(journal) + journal.resolveDispatch = (input, cards) => { + counts.rejections += input.state === 'rejected' ? 1 : 0 + return resolveDispatch(input, cards) + } + const { heldSends } = adapter + adapter.heldSends = (sessionId) => { + const held = heldSends(sessionId) + counts.heldSets += inside ? 1 : 0 + counts.heldVisits += inside ? (held?.size ?? 0) : 0 + return held + } + return counts +} + +/** A person's pick of another permission mode, which writes the chat's record only. */ +async function pickPermission(value: string): Promise { + const fields = { key: 'permissionMode', value } + const picked = await host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + expect(picked).toMatchObject({ ok: true }) +} + +// Each send is checked twice, before and after the wait for its child: the second check is what +// sees a change made during that wait, so it must stay, and stay one pass over history. +it('re-derives the retirement check after the wait, so a sign-out reported in it is honored', async () => { + const connection = claude.connections[0]! + const counts = countRetirementReads() + counts.afterCheck = async (check) => (check === 1 ? olderHostSends(1, 'notSignedIn') : undefined) + await send('CHANGED prompt') + await eventually(() => expect(written(claude.connections[1]!, 'CHANGED prompt')).toBeDefined()) + expect(written(connection, 'CHANGED prompt')).toBeUndefined() + expect(connection.closeCount).toBe(1) +}) + +describe.each([20, 200])('with %i earlier sends', (count) => { + it('checks an ordinary send twice, each check one pass over history', async () => { + await olderHostSends(count, 'accepted') + const counts = countRetirementReads() + await send('ORDINARY prompt') + await eventually(() => expect(written(claude.connections[0]!, 'ORDINARY prompt')).toBeDefined()) + const { items, submissions } = await snapshot() + expect(submissions).toHaveLength(count + 1) + expect(counts).toMatchObject({ + checks: 2, + linkageVisits: 2 * items.length, + submissionsCopied: 2 * submissions.length, + newestTurn: 0, + heldSets: 0 + }) + }) + + it('keeps an agent mid-turn through a permission change, deriving no wake debt', async () => { + await olderHostSends(count, 'accepted') + const connection = claude.connections[0]! + await send('FIRST prompt') + await eventually(() => expect(written(connection, 'FIRST prompt')).toBeDefined()) + frame(connection, { + ...written(connection, 'FIRST prompt')!, + isReplay: true, + parent_tool_use_id: null + }) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).not.toBeNull()) + await pickPermission('ask') + const counts = countRetirementReads() + await send('STEER prompt') + await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) + expect(counts).toMatchObject({ checks: 2, newestTurn: 0 }) + expect(connection.closeCount).toBe(0) + }) + + it('refuses each queued model change behind an admitted send, reading what the CLI holds once per check', async () => { + claudeFrames.initProof = 'none' + const connection = claude.connections[0]! + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'haiku-root', + model: 'claude-haiku-4-5', + permissionMode: 'default' + }) + await olderHostSends(count) + const admitted = await send('ADMITTED prompt') + await eventually(() => expect(written(connection, 'ADMITTED prompt')).toBeDefined()) + expect(adapter.holdsDispatch(SESSION)).toBe(false) + const counts = countRetirementReads() + const blocked: string[] = [] + for (let index = 0; index < count; index += 1) { + blocked.push(await send(`BLOCKED ${index}`, { model: 'opus' })) + } + await eventually(async () => { + const rejected = (await snapshot()).submissions.filter( + (entry) => entry.dispatchState === 'rejected' + ) + expect(rejected.map((entry) => entry.clientMessageId).sort()).toEqual([...blocked].sort()) + }) + // The admitted send is the one send the CLI holds, read once per check however many older + // unanswered rows come before it. + expect(counts).toMatchObject({ + checks: count, + rejections: count, + heldSets: count, + heldVisits: count + }) + expect( + (await snapshot()).submissions.find((entry) => entry.clientMessageId === admitted) + ).toMatchObject({ dispatchState: 'pending' }) + expect(connection.closeCount).toBe(0) + expect(connection.calls.some(({ subtype }) => subtype === 'set_model')).toBe(false) + expect(claude.connections).toHaveLength(1) + }, 60_000) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts index 6aceb5a611cb..b0a767e98b6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -198,14 +198,31 @@ export class StructuredAgentSessionCurrentWork { /** The live child still holds this send as its work, or the host is still handing it over. */ private holds(submission: AgentJournalSubmission): boolean { + return this.holdsAny([submission]) + } + + /** `holds` for any of these sends, reading what the child holds once and only when one is asked + * about: the child's answer is rebuilt on each read and never outlives the call. */ + private holdsAny(submissions: readonly AgentJournalSubmission[]): boolean { + if (submissions.length === 0) { + return false + } const held = this.heldSends?.() + const handingOver = structuredAgentSessionHandingOver(this.journal) return ( held === undefined || - held.has(submission.clientMessageId) || - structuredAgentSessionHandingOver(this.journal).has(submission.clientMessageId) + submissions.some( + ({ clientMessageId }) => held.has(clientMessageId) || handingOver.has(clientMessageId) + ) ) } + /** Whether a send handed to the live generation is still owed: `owesSend` over every send not + * queued, in one pass. */ + holdsHandedOverSend(): boolean { + return this.holdsAny(this.unansweredSends()) + } + /** Whether the live generation's execution produced this item. */ isCurrentItem(itemId: string): boolean { if (this.liveFence === null) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index cf43e8d72d82..28563ffb32e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -111,23 +111,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { const record = deps.store.getRecord(sessionId) return record !== null && deps.hasOpenDispatch?.(record) === true }, - providerHoldsDispatch: () => { - if (deps.adapter.holdsDispatch?.(sessionId) === true) { - return true - } - const work = hostStructuredAgentSessionCurrentWork( + providerHoldsDispatch: () => + deps.adapter.holdsDispatch?.(sessionId) === true || + hostStructuredAgentSessionCurrentWork( { store: deps.store, sessions }, sessionId - ) - return ( - session?.journal - .submissions() - .some( - (submission) => - !isQueuedAgentJournalSubmission(submission) && work?.owesSend(submission) - ) === true - ) - } + )?.holdsHandedOverSend() === true }, launchPermission: async () => { const record = deps.store.getRecord(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index f38ffcb40d30..3908fa76e4bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -239,6 +239,54 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { expect(before.stopAgent).toHaveBeenCalledWith('session') }) + it('says why a model change waits, reading wake debt only when open work leaves that open', async () => { + const settled: AgentChildWorkView = { + ...liveChildWork, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 200 + } + const refuse = async (work: Partial, children: AgentChildWorkView[]) => { + const childWork = vi.fn(() => children) + const refusal = await retireOutgrownStructuredAgentSessionChild( + 'session', + conversation({ newestTurnEndedAt: 100 }), + { + work: { ...idle, childWork, ...work }, + next: { + clientMessageId: 'next', + fence: 2, + payloadFingerprint: 'next', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 0, + resolvedAt: null + }, + requiresModelTransitionRestart: () => true, + stopAgent: vi.fn(async () => undefined), + logger: { warn: vi.fn(), error: vi.fn() } + } + ) + return { refusal, wakeDebtReads: childWork.mock.calls.length } + } + const waits = (reason: string, wakeDebtReads: number) => ({ + refusal: { details: { reason } }, + wakeDebtReads + }) + expect(await refuse({ childWorkOpen: () => true }, [settled])).toMatchObject( + waits('backgroundTasksRunning', 0) + ) + expect(await refuse({}, [settled])).toMatchObject(waits('backgroundTasksRunning', 1)) + expect(await refuse({ providerHoldsDispatch: () => true }, [settled])).toMatchObject( + waits('backgroundTasksRunning', 1) + ) + expect(await refuse({ providerHoldsDispatch: () => true }, [])).toMatchObject( + waits('messagesUnsettled', 1) + ) + }) + it('logs a stop that fails and lets the send go on', async () => { const { warn } = await retire( signedOut, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 33cb67d8a489..82b7bedf36a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -156,20 +156,20 @@ export async function retireOutgrownStructuredAgentSessionChild( if (!otherReason && !modelTransition) { return } - const wakeDebt = owesWakeUp(session.journal, deps.work.childWork()) - if ( - structuredAgentSessionChildHasOpenWork( - modelTransition && !otherReason ? { ...deps.work, hasOpenDispatch: () => false } : deps.work - ) || - wakeDebt - ) { + const open = structuredAgentSessionChildHasOpenWork( + modelTransition && !otherReason ? { ...deps.work, hasOpenDispatch: () => false } : deps.work + ) + if (open && !modelTransition) { + return + } + // Wake debt is derived only where it decides: keeping an idle child, or why a model change waits. + const background = + (open && deps.work.childWorkOpen()) || owesWakeUp(session.journal, deps.work.childWork()) + if (open || background) { return modelTransition ? { code: 'agent_session_operation_invalid', - details: { - reason: - deps.work.childWorkOpen() || wakeDebt ? 'backgroundTasksRunning' : 'messagesUnsettled' - } + details: { reason: background ? 'backgroundTasksRunning' : 'messagesUnsettled' } } : undefined } From 7abdd6c8685c6ba29aeb736e5e51a0bc8e083b03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:55:17 -0700 Subject: [PATCH 09/16] Keep active Claude input on its turn choices --- ...aude-structured-dispatch-admission.test.ts | 21 +- src/main/claude/claude-structured-dispatch.ts | 35 +- .../agent-session-journal/journal-store.ts | 5 +- .../journal-submission-writer.ts | 15 +- .../structured-agent-session-adapter.ts | 3 + ...t-session-claude-opening-send-hold.test.ts | 4 +- ...-agent-session-claude-subagent-row.test.ts | 299 +++++++++++++++++- .../structured-agent-session-delivery-loop.ts | 44 ++- ...uctured-agent-session-execution-options.ts | 6 + .../structured-agent-session-handover.ts | 84 ++++- .../structured-agent-session-host-delivery.ts | 1 + ...tured-agent-session-outgrown-child.test.ts | 15 +- ...structured-agent-session-outgrown-child.ts | 48 ++- src/shared/agent-session-failure-copy.ts | 2 + .../agent-session-failure-words.test.ts | 16 + src/shared/agent-session-failure-words.ts | 6 +- .../agent-session-refusal-reason-words.ts | 2 +- src/shared/agent-session-write-notice-copy.ts | 3 +- 18 files changed, 535 insertions(+), 74 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index a7501b39d88f..f6acc14fb450 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -32,7 +32,8 @@ describe('Claude structured dispatch admission', () => { await dispatchClaudeTurn(session, { clientMessageId: 'client-b', body: userMessage([{ type: 'text', text: 'b' }]), - requestedAt: 200 + requestedAt: 150, + admitPreparedInput: async (offer) => offer(200) }) await dispatchClaudeTurn(session, { clientMessageId: 'client-c', @@ -130,7 +131,7 @@ describe('Claude structured dispatch admission', () => { it('bounds pending replay identities instead of retaining an unbounded queue', async () => { const session = sessionFor() - for (let index = 0; index < 64; index += 1) { + for (let index = 0; index < 63; index += 1) { await expect( dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, @@ -139,6 +140,22 @@ describe('Claude structured dispatch admission', () => { ).resolves.toEqual({ state: 'admitted' }) } + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'prepared-at-capacity', + body: userMessage([{ type: 'text', text: 'prepared before the last slot filled' }]), + admitPreparedInput: async (offer) => { + await dispatchClaudeTurn(session, { + clientMessageId: 'last-slot', + body: userMessage([{ type: 'text', text: 'fills the last slot' }]) + }) + await offer(200) + } + }) + ).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'writeFailed' } }) + expect(session.dispatchWaiters).toHaveLength(64) + expect(session.connection.send).toHaveBeenCalledTimes(64) + await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-over-capacity', diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 55fe77f1fc36..23339f51f2cd 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,7 +7,10 @@ import { waitForReplay } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { @@ -124,6 +127,9 @@ export async function dispatchClaudeTurn( /** The frame's uuid, for a caller that correlates the provider's answer to it. */ sentUuid?: string signal?: AbortSignal + admitPreparedInput?: Parameters< + StructuredAgentSessionAdapter['dispatch'] + >[0]['admitPreparedInput'] }, beforeDispatch?: () => Promise ): Promise { @@ -140,7 +146,7 @@ export async function dispatchClaudeTurn( // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) const sentUuid = input.sentUuid ?? randomUUID() - const arm = () => { + const arm = (requestedAt = input.requestedAt) => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. session.translator?.markContextActivity() @@ -150,7 +156,7 @@ export async function dispatchClaudeTurn( sentUuid, claudeDispatchContentKey(content), input.clientMessageId ?? null, - input.requestedAt ?? null + requestedAt ?? null ) } const message = { @@ -160,7 +166,7 @@ export async function dispatchClaudeTurn( parent_tool_use_id: null, session_id: session.providerSessionId } - const pending = { replay: beforeDispatch ? undefined : arm() } + const pending = { replay: beforeDispatch || input.admitPreparedInput ? undefined : arm() } const authorize = beforeDispatch ? async () => { await beforeDispatch() @@ -171,9 +177,24 @@ export async function dispatchClaudeTurn( } : undefined try { - await (authorize - ? session.connection.send(message, authorize) - : session.connection.send(message)) + if (input.admitPreparedInput) { + await beforeDispatch?.() + await input.admitPreparedInput( + (requestedAt) => + new Promise((resolve) => { + if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { + throw claudeUnwrittenUserMessageError(new Error(DISPATCH_REJECTED_QUEUE_FULL)) + } + pending.replay = arm(requestedAt) + resolve(session.connection.send(message)) + }), + () => session.translator?.currentTurnId ?? null + ) + } else { + await (authorize + ? session.connection.send(message, authorize) + : session.connection.send(message)) + } } catch (error) { const replay = pending.replay if (!replay) { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index aee913eb5ba9..00e607ef06b9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -357,9 +357,10 @@ export class AgentSessionJournal { /** A dispatch transition (`JournalSubmissionWriter.resolveDispatch`). */ resolveDispatch( input: ResolveDispatchInput, - cards?: () => JournalQueuePlan + cards?: () => JournalQueuePlan, + receipt?: JournalOperationReceipt ): Promise { - return this.submissionWriter.resolveDispatch(input, cards) + return this.submissionWriter.resolveDispatch(input, cards, receipt) } /** A provider's late receipt (`JournalSubmissionWriter.acceptLate`). */ diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index 1b7f9f33e9a9..84d716a76ec2 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -89,11 +89,12 @@ export class JournalSubmissionWriter { resolveDispatch( input: ResolveDispatchInput, /** Card rows that commit with this one, planned at its turn on the lane. */ - cards?: () => JournalQueuePlan + cards?: () => JournalQueuePlan, + receipt?: JournalOperationReceipt ): Promise { const dispatch = journalDispatchRowBuilder(this.deps.state, input) if (!cards) { - return this.deps.rowWriter.append(dispatch) + return this.deps.rowWriter.append(dispatch, receipt) } let planned: JournalQueuePlan | undefined return this.deps.rowWriter @@ -103,8 +104,14 @@ export class JournalSubmissionWriter { return [dispatch, ...planned.rows] }, { - write: (db) => planned?.receipt.write(db), - committed: () => planned?.receipt.committed() + write: (db, row) => { + planned?.receipt.write(db) + receipt?.write(db, row) + }, + committed: () => { + planned?.receipt.committed() + receipt?.committed() + } }, (rows) => rows[0] ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index d69b20bab602..6ca11df3ea25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionPreparedAdmission } from './structured-agent-session-execution-options' import type { AiVaultConversationCopy } from '../../../shared/ai-vault-resume-preparation' import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability' import type { @@ -283,6 +284,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & requestedAt?: number /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise + /** Prepared local input; the host commits write-ahead custody before synchronously offering it. */ + admitPreparedInput?: StructuredAgentSessionPreparedAdmission /** Local preparation boundary before choices can mutate the provider. */ onInputPreparation?: () => void /** In-process checkpoint after applying choices, before capturing execution evidence. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 266b6443f1b3..3d22e2423b35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -670,9 +670,9 @@ function countRetirementReads() { return newestTurn() } const resolveDispatch = journal.resolveDispatch.bind(journal) - journal.resolveDispatch = (input, cards) => { + journal.resolveDispatch = (input, cards, receipt) => { counts.rejections += input.state === 'rejected' ? 1 : 0 - return resolveDispatch(input, cards) + return resolveDispatch(input, cards, receipt) } const { heldSends } = adapter adapter.heldSends = (sessionId) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts index c5e9ed368dd3..9d5e24003e8b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -9,10 +9,15 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { recordAgentSessionRanOptions } from '../../runtime/agent-session-record-options' import { isSubagentBlock } from '../../../shared/native-chat-types' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' -import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture' +import { + MOVED_TO_BACKGROUND, + RESUMED_BY_MESSAGE +} from '../../claude/claude-captured-task-frames.test-fixture' import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { @@ -47,6 +52,8 @@ import { const CALLER = { callerKey: 'client-1' } +let releaseBarriers: (() => void)[] = [] +let claudeFrames: { replayUuid: null; initProof?: 'none' } let root: string let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter @@ -60,9 +67,11 @@ let clock = NOW beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-')) clock = NOW + releaseBarriers = [] resetHostTestOperationIds() const log = recordingStructuredAgentSessionLogger() - claude = fakeClaude({ replayUuid: null }) + claudeFrames = { replayUuid: null } + claude = fakeClaude(claudeFrames) orchestration = new OrchestrationDb(':memory:') adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async ({ permissionMode }) => ({ @@ -118,6 +127,7 @@ beforeEach(async () => { }) afterEach(async () => { + releaseBarriers.forEach((release) => release()) setStructuredAgentSessionHost(null) await adapter.closeAll() await host.flushAllStreamedEvents() @@ -551,3 +561,288 @@ it('keeps a helper the next child resumes while it is still starting working, pa expect(claude.connections).toHaveLength(2) expect(await savedRows()).toEqual([expect.objectContaining({ id: 'agent-1', state: 'working' })]) }) + +it('steers on current choices and returns the same unwritten input to new-turn selection at admission', async () => { + const written = (on: FakeConnection, text: string) => + on.sent.find((message) => JSON.stringify(message).includes(text)) + const snapshot = async () => { + await host.flushStreamedEvents(SESSION) + return host.journalSnapshot(SESSION) + } + const send = async (text: string, options?: Record) => { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, options }), + body, + options + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId + } + claudeFrames.initProof = 'none' + const first = claude.connections[0]! + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const dispatch = vi.spyOn(adapter, 'dispatch') + const recorded = vi.spyOn(store, 'reviseSessionOptions') + const idle = () => + eventually(() => + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + const init = (on: FakeConnection, permissionMode = 'default') => + frame(on, { + type: 'system', + subtype: 'init', + uuid: `init-${claude.connections.length}`, + model: on.launch.options.model === 'opus' ? 'claude-opus-4-6' : 'claude-haiku-4-5', + permissionMode + }) + const echo = (on: FakeConnection, text: string) => + frame(on, { ...written(on, text)!, isReplay: true, parent_tool_use_id: null }) + const finish = (on: FakeConnection, text: string) => { + const input = written(on, text)! + frame(on, { + type: 'assistant', + uuid: `reply-${text}`, + parent_tool_use_id: null, + message: { + id: `reply-${text}`, + role: 'assistant', + content: [{ type: 'text', text: `${text} result` }] + }, + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { + type: 'result', + subtype: 'success', + uuid: `result-${text}`, + is_error: false, + terminal_reason: 'completed', + user_message_uuid: input.uuid, + user_message_uuids: [input.uuid] + }) + frame(on, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + } + const opened = async (text: string, options?: Record) => { + const id = await send(text, options) + await eventually(() => expect(written(claude.connections.at(-1)!, text)).toBeDefined()) + const on = claude.connections.at(-1)! + init(on) + echo(on, text) + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).not.toBeNull()) + await idle() + return { id, on } + } + init(first) + await opened('A') + for (const captured of MOVED_TO_BACKGROUND.filter(({ at }) => at <= 11_139)) { + frame(first, captured.frame) + } + await eventually(async () => + expect(await savedRows()).toEqual( + expect.arrayContaining([expect.objectContaining({ state: 'working' })]) + ) + ) + const choices = { model: 'opus', effort: 'high', fastMode: 'true' } + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: choices, + now: NOW + }) + const controls = first.calls.length + const b = await send('B', choices) + await eventually(() => expect(written(first, 'B')).toBeDefined()) + await idle() + expect(first.calls.slice(controls)).toEqual([]) + expect(first.closeCount).toBe(0) + expect(claude.connections).toHaveLength(1) + const bIndex = dispatch.mock.calls.findIndex(([input]) => input.clientMessageId === b) + expect(dispatch.mock.calls[bIndex]![0].executionOptions).toBeUndefined() + expect(await dispatch.mock.results[bIndex]!.value).toMatchObject({ + state: 'admitted', + ranOptions: { model: 'claude-haiku-4-5' } + }) + expect( + recorded.mock.calls.findLast(([update]) => update === recordAgentSessionRanOptions)?.[1] + ).toMatchObject({ model: 'claude-haiku-4-5' }) + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === b)?.options + ).toEqual(choices) + expect(await savedRows()).toEqual( + expect.arrayContaining([expect.objectContaining({ state: 'working' })]) + ) + echo(first, 'B') + for (const captured of MOVED_TO_BACKGROUND.filter(({ at }) => at >= 51_275 && at <= 52_441)) { + frame(first, captured.frame) + } + await eventually(async () => + expect(await savedRows()).toEqual( + expect.arrayContaining([expect.objectContaining({ state: 'completed' })]) + ) + ) + finish(first, 'A') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + const c = await opened('C', choices) + expect(c.on).not.toBe(first) + expect(c.on.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + finish(c.on, 'C') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + await opened('same choice', choices) + expect(claude.connections).toHaveLength(2) + + // Preparation is complete but pending has not entered the journal lane: still reversible. + const release = Promise.withResolvers() + releaseBarriers.push(() => release.resolve()) + const resolve = journal.resolveDispatch.bind(journal) + let held = false + const pending = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation(async (input, cards, receipt) => { + if (receipt && !held) { + held = true + await release.promise + } + return resolve(input, cards, receipt) + }) + const lateCapture = { ...choices, model: 'haiku' } + const late = await send('late preparation', lateCapture) + await eventually(() => expect(held).toBe(true)) + const queuedLate = (await snapshot()).submissions.find( + ({ clientMessageId }) => clientMessageId === late + )! + expect(queuedLate.options).toEqual(lateCapture) + expect(queuedLate.handedOverAt).toBeUndefined() + expect(isQueuedAgentJournalSubmission(queuedLate)).toBe(true) + expect(written(c.on, 'late preparation')).toBeUndefined() + expect(adapter.heldSends(SESSION)?.has(late)).toBe(false) + finish(c.on, 'same choice') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + release.resolve() + await eventually(() => + expect(written(claude.connections.at(-1)!, 'late preparation')).toBeDefined() + ) + pending.mockRestore() + const d = claude.connections.at(-1)! + expect(d).not.toBe(c.on) + expect(d.launch.options).toMatchObject({ model: 'haiku', permissionMode: 'auto' }) + init(d) + echo(d, 'late preparation') + await idle() + + // The end is decoded after pending joins the lane, but its lifecycle fold queues behind it. + const target = host.currentWork(SESSION)!.activeTurnId() + let staged = false + let rolledBack = false + const queued = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation((input, cards, receipt) => { + if (!receipt || staged) { + return resolve(input, cards, receipt) + } + staged = true + let result!: ReturnType + return journal + .readInOrder(() => { + result = resolve(input, cards, { + ...receipt, + write: (db, row) => { + expect(host.currentWork(SESSION)?.activeTurnId()).toBe(target) + try { + receipt.write(db, row) + } catch (error) { + rolledBack = true + throw error + } + } + }) + finish(d, 'late preparation') + expect(host.currentWork(SESSION)?.activeTurnId()).toBe(target) + }) + .then(() => result) + }) + const lastCapture = { ...choices, model: 'opus' } + const final = await send('late journal', lastCapture) + await eventually(() => expect(written(claude.connections.at(-1)!, 'late journal')).toBeDefined()) + queued.mockRestore() + expect(rolledBack).toBe(true) + const e = claude.connections.at(-1)! + expect(e).not.toBe(d) + expect(e.launch.options).toMatchObject({ model: 'opus', permissionMode: 'auto' }) + init(e, 'auto') + echo(e, 'late journal') + await idle() + + // A returned-promise delay after actual COMMIT/offer is already past local admission. + const offered = Promise.withResolvers() + const acknowledge = Promise.withResolvers() + releaseBarriers.push(() => acknowledge.resolve()) + const admitted = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation((input, cards, receipt) => { + const result = resolve(input, cards, receipt) + return receipt + ? result.then(async (cursor) => { + offered.resolve() + await acknowledge.promise + return cursor + }) + : result + }) + const currentControls = e.calls.length + const after = await send('after admission', { model: 'haiku' }) + await eventually(() => expect(written(e, 'after admission')).toBeDefined()) + await offered.promise + expect(e.calls).toHaveLength(currentControls) + finish(e, 'late journal') + await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull()) + acknowledge.resolve() + await idle() + admitted.mockRestore() + echo(e, 'after admission') + finish(e, 'after admission') + await eventually(async () => + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === after) + ?.dispatchState + ).toBe('accepted') + ) + for (const [id, text, capture] of [ + [late, 'late preparation', lateCapture], + [final, 'late journal', lastCapture] + ] as const) { + expect( + claude.connections + .flatMap(({ sent }) => sent) + .filter( + (message) => message.message && JSON.stringify(message.message).includes(`"${text}"`) + ) + ).toHaveLength(1) + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === id) + ).toMatchObject({ clientMessageId: id, options: capture, dispatchState: 'accepted' }) + } + expect( + claude.connections + .flatMap(({ sent }) => sent) + .filter((message) => JSON.stringify(message).includes('after admission')) + ).toHaveLength(1) + expect(claude.connections).toHaveLength(4) + expect( + (await snapshot()).items.flatMap(({ body }) => + body.kind === 'message' + ? body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + ).toEqual( + expect.arrayContaining([ + 'A result', + 'C result', + 'same choice result', + 'late preparation result', + 'late journal result' + ]) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 616d118b65e7..53e41fcb178f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -15,11 +15,7 @@ // settles it first (`journal-unsent-send-hold.ts`). import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' -import type { - AgentJournalCursor, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -39,6 +35,7 @@ import { } from './structured-agent-session-start-failure-row' import { beginSubmissionHandover, + type StructuredAgentSessionPreparedDelivery, type StructuredAgentSessionPendingHandover } from './structured-agent-session-handover' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' @@ -52,7 +49,10 @@ import { waitsForCodexTurnOptions } from './structured-agent-session-message-opt import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import { rejectBlockedStructuredAgentSessionTransition } from './structured-agent-session-outgrown-child' +import { + rejectBlockedStructuredAgentSessionTransition, + type StructuredAgentSessionChildTransition +} from './structured-agent-session-outgrown-child' export type StructuredAgentSessionDeliveryLoopDeps = { deliveryOrder: StructuredAgentSessionDeliveryOrder @@ -68,7 +68,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { retireOutgrownChild: ( sessionId: string, next: AgentJournalSubmission - ) => Promise + ) => Promise readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity @@ -96,17 +96,6 @@ export type StructuredAgentSessionDeliveryLoopDeps = { recordRanOptions?: StructuredAgentSessionRanRecorder } -type Prepared = - | 'stop' - | 'continue' - | { start: StructuredAgentSessionStartClaim; acceptedBy: AgentJournalCursor } - | { - ok: true - awaited: StructuredAgentSessionProviderChildIdentity - readiness: StructuredAgentSessionStartReadiness | null - before: Promise - } - /** A failed start before it is worded; `fail` words it once, through the one wording point. */ type StartFailure = NonNullable> @@ -234,7 +223,7 @@ export class StructuredAgentSessionDeliveryLoop { } /** Settles what an earlier host process left queued, then makes the session ready. */ - private async prepare(sessionId: string): Promise { + private async prepare(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -263,7 +252,9 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } - if (await rejectBlockedStructuredAgentSessionTransition(this.deps, sessionId, oldest)) { + if ( + (await rejectBlockedStructuredAgentSessionTransition(this.deps, sessionId, oldest)) === true + ) { return 'continue' } const child = this.deps.sessions.get(sessionId)?.child @@ -323,10 +314,12 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } // Re-derive for the actual next input after proving the awaited child still exists. - if ( - (await rejectBlockedStructuredAgentSessionTransition(this.deps, sessionId, next)) || - session.child !== awaitedChild - ) { + const transition = await rejectBlockedStructuredAgentSessionTransition( + this.deps, + sessionId, + next + ) + if (transition === true || session.child !== awaitedChild) { return 'continue' } if ( @@ -354,7 +347,8 @@ export class StructuredAgentSessionDeliveryLoop { }, next, // Re-read right before the provider call, which runs outside the serialize. - () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close + () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close, + typeof transition === 'function' ? transition : undefined ) return pending ?? 'continue' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts index b26b3788bf51..d3b9910b8dce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -40,3 +40,9 @@ export function captureStructuredExecutionOptions( ) return refusal ? { refusal } : { options } } + +/** Local preparation only: no journal or wire representation. */ +export type StructuredAgentSessionPreparedAdmission = ( + offer: (requestedAt: number) => Promise, + currentTurn: () => string | null +) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 730aecce5df0..2c7e7f985f4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -1,6 +1,9 @@ // Handing one accepted message to the provider: two commits around a provider call that runs // outside the session's serialize, so a send is accepted while the call is in flight. +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import { agentSessionFailureFact, providerDiagnosticOf @@ -12,13 +15,16 @@ import { isAbortSignalReason } from '../../../shared/abort-signal-reason' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { + AgentJournalCursor, AgentJournalMessageItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' import { handOverStructuredAgentSessionCommand, structuredAgentSessionHandoverOrigin, @@ -32,6 +38,17 @@ import { whileStructuredAgentSessionHandingOver } from './structured-agent-session-handing-over' +export type StructuredAgentSessionPreparedDelivery = + | 'stop' + | 'continue' + | { start: StructuredAgentSessionStartClaim; acceptedBy: AgentJournalCursor } + | { + ok: true + awaited: StructuredAgentSessionProviderChildIdentity + readiness: StructuredAgentSessionStartReadiness | null + before: Promise + } + export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { acquireAborts?: Pick deliveryOrder?: StructuredAgentSessionDeliveryOrder @@ -57,7 +74,8 @@ export async function beginSubmissionHandover( ctx: AgentSessionHandoverContext, submission: AgentJournalSubmission, /** Whether the child the row was written for is still the one to call. */ - stillCurrent: () => boolean + stillCurrent: () => boolean, + keepsTurn?: (providerTurn: () => string | null) => boolean ): Promise { const { clientMessageId } = submission const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) @@ -93,14 +111,24 @@ export async function beginSubmissionHandover( ) const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) const queued = submission.queuedMessageId !== undefined + const executionOptions = keepsTurn ? undefined : submission.options + let reconsider = false + const pending = (receipt?: JournalOperationReceipt) => + ctx.journal.resolveDispatch( + { + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope, + ...(carried ? { carries: carried.covers } : {}) + }, + undefined, + receipt + ) try { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'pending', - fence: ctx.fence, - turnScope, - ...(carried ? { carries: carried.covers } : {}) - }) + if (!keepsTurn) { + await pending() + } } catch (error) { released() release?.() @@ -128,19 +156,47 @@ export async function beginSubmissionHandover( clientMessageId, carried ? withStoppedHistory(body, carried.messages) : body, requestedAt, - submission.options, + executionOptions, queued, - attempt?.signal + attempt?.signal, + keepsTurn + ? async (offer, providerTurn) => { + let written!: Promise + let origin!: number + await pending({ + write: (_db, row) => { + if (!row) { + throw new Error('pending dispatch receipt has no row') + } + origin = row.ts + attempt?.signal.throwIfAborted() + if (!stillCurrent() || !keepsTurn(providerTurn)) { + reconsider = true + throw new AgentSessionPreDispatchError('steer-target-ended') + } + }, + committed: () => { + written = offer(origin) + // The offer stays observed even if the subsequent fold throws. + void written.catch(() => undefined) + } + }) + await written + } + : undefined ) : null } finally { attempt?.end() } return async () => { + if (reconsider) { + return + } await resolveHandover(ctx, clientMessageId, outcome) if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { await ctx.recordRanOptions?.({ - message: submission.options, + message: executionOptions, executed: outcome.ranOptions }) } @@ -206,7 +262,10 @@ async function dispatchSafely( requestedAt: number, executionOptions: AgentSessionExecutionOptions | undefined, queued: boolean, - signal?: AbortSignal + signal?: AbortSignal, + admitPreparedInput?: Parameters< + AgentSessionHandoverContext['adapter']['dispatch'] + >[0]['admitPreparedInput'] ): Promise { try { const outcome = await ctx.adapter.dispatch({ @@ -217,6 +276,7 @@ async function dispatchSafely( ...(signal ? { signal } : {}), fence: ctx.fence, requestedAt, + ...(admitPreparedInput ? { admitPreparedInput } : {}), onInputPreparation: ctx.recordRanOptions?.prepareInput, onExecutionSnapshot: ctx.recordRanOptions?.checkpoint }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 28563ffb32e6..cdd0fad6627c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -95,6 +95,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { : sessions.get(sessionId) return retireOutgrownStructuredAgentSessionChild(sessionId, session, { next, + turnScopedSteering: deps.store.getRecord(sessionId)?.provider === 'claude', requiresModelTransitionRestart: (submission) => session?.child?.phase === 'ready' && deps.adapter.requiresModelTransitionRestart?.({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 3908fa76e4bb..2ef6123aae7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -40,7 +40,7 @@ describe('a send after the agent said it is not signed in', () => { const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - it('goes to a new agent, and the new agent is kept for the sends after it', async () => { + it('recovers a signed-out child for mail after sign-in, then reuses the new child', async () => { await foundRestTestChat(rig) rig.adapter.dispatch.mockImplementationOnce(async () => ({ state: 'rejected', @@ -53,7 +53,18 @@ describe('a send after the agent said it is not signed in', () => { expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) rig.adapter.closeSession.mockClear() - expect((await rig.host.send(CALLER, restTestSend('signed in now', fence()))).ok).toBe(true) + const mail = restTestSend('signed in now: worker mail', fence()) + mail.body.from = { + kind: 'agent', + senders: [], + orchestration: { + message: 'mail-notice', + mailbox: 'agent:worker', + dispatchId: null, + messages: [] + } + } + expect((await rig.host.send(CALLER, mail)).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 82b7bedf36a4..89177121589c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -22,22 +22,27 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +export type StructuredAgentSessionChildTransition = + | AgentSessionRefusalReference + | ((providerTurn: () => string | null) => boolean) + | void + export async function rejectBlockedStructuredAgentSessionTransition( deps: { retireOutgrownChild: ( sessionId: string, next: AgentJournalSubmission - ) => Promise + ) => Promise sessions: ReadonlyMap> conversationFence: (sessionId: string) => number failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext }, sessionId: string, next: AgentJournalSubmission -): Promise { +): Promise string | null) => boolean)> { const refusal = await deps.retireOutgrownChild(sessionId, next) - if (!refusal) { - return false + if (!refusal || typeof refusal === 'function') { + return refusal ?? false } await deps.sessions.get(sessionId)?.journal.resolveDispatch({ clientMessageId: next.clientMessageId, @@ -135,32 +140,51 @@ export async function retireOutgrownStructuredAgentSessionChild( } /** The mode a start of the chat would launch with now. */ launchPermission?: () => Promise + turnScopedSteering?: boolean next?: AgentJournalSubmission requiresModelTransitionRestart?: (next: AgentJournalSubmission) => boolean stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } -): Promise { +): Promise { if (!session) { return } const body = deps.next && session.journal.itemBody(agentJournalSubmissionKey(deps.next.clientMessageId)) - if (body?.kind === 'message' && body.from?.orchestration?.message === 'mail-notice') { - return - } + const mail = body?.kind === 'message' && body.from?.orchestration?.message === 'mail-notice' + const child = session.child + const turnId = + deps.turnScopedSteering && + child?.phase === 'ready' && + !child.close && + body?.kind === 'message' && + !body.command && + !mail && + deps.next?.queuedMessageId === undefined + ? deps.work.currentWork()?.activeTurnId() + : null + const steer = turnId + ? (providerTurn: () => string | null) => + session.child === child && + !child.close && + deps.work.currentWork()?.activeTurnId() === turnId && + providerTurn() === turnId + : undefined + const keepOptions = mail || steer !== undefined const otherReason = structuredAgentSessionChildReportedSignedOut(session) || - (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) - const modelTransition = deps.next && deps.requiresModelTransitionRestart?.(deps.next) === true + (!keepOptions && (await launchedWithOtherPermissionMode(session.child, deps.launchPermission))) + const modelTransition = + !keepOptions && deps.next && deps.requiresModelTransitionRestart?.(deps.next) === true if (!otherReason && !modelTransition) { - return + return steer } const open = structuredAgentSessionChildHasOpenWork( modelTransition && !otherReason ? { ...deps.work, hasOpenDispatch: () => false } : deps.work ) if (open && !modelTransition) { - return + return steer } // Wake debt is derived only where it decides: keeping an idle child, or why a model change waits. const background = diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 046562434924..0f681414b6c8 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -18,6 +18,7 @@ export const WAIT_FOR_BACKGROUND_TASKS = 'Wait for the background tasks to finis export const AGENT_STARTING = 'The agent is still starting.' export const WAIT_FOR_START = 'Wait for the agent to finish starting.' export const AGENT_STILL_WORKING = 'The agent is still working.' +export const WAIT_FOR_TURN = 'Wait for the agent to finish responding, or stop it.' /** Every piece a failure sentence is made of, whole so desktop can translate each on its own. * `{{agent}}` is the agent's name or `theAgent`, `{{command}}` a conversation command's name; the @@ -128,6 +129,7 @@ export const AGENT_SESSION_FAILURE_COPY = { agentStarting: AGENT_STARTING, waitForStart: WAIT_FOR_START, agentStillWorking: AGENT_STILL_WORKING, + waitForTurn: WAIT_FOR_TURN, runCommandWhenDone: "Run /{{command}} when it's done.", commandAfterAnswer: "Answer the agent's question or approval, then run /{{command}}.", compactionFailed: 'Compaction failed.', diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 7790c71a6bdf..7a52272b7659 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -370,3 +370,19 @@ describe('an attachment problem as a reader meets it', () => { ).toEqual({ kind: 'attachmentInvalid' }) }) }) + +it('keeps an unsettled ordinary send on the existing working and wait wording', () => { + expect( + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'messagesUnsettled' } + } + }, + 'rejection', + { agentName: 'Claude' } + ) + ).toBe('The agent is still working. Wait for the agent to finish responding, or stop it.') +}) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 7e6e50020e91..a44d63f3c346 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -138,6 +138,9 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { if (installationFailure) { return installationFailure } + if (!context.command && fact.refusal?.details?.reason === 'messagesUnsettled') { + return joinSentences([say('agentStillWorking'), say('waitForTurn')]) + } const failed = say(verb, agent(say, context)) const blocked = commandRefusedByReason(say, context, fact) if (blocked) { @@ -304,8 +307,7 @@ export function agentSessionFailureSentence( /** Desktop passes its translations; the host and the phone keep English. */ say: AgentSessionFailureSay = sayAgentSessionFailureEnglish ): string { - const sentence: Sentence = FAILURE_SENTENCES[fact.kind] - return sentence(context, fact, surface, say) + return FAILURE_SENTENCES[fact.kind](context, fact, surface, say) } /** The markers released clients hide, for the rejections that had one before rows carried a fact. diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 974417fd6304..10f2d1583ecc 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -94,7 +94,7 @@ const REASON_WORDS = { turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + messagesUnsettled: causeWords('agentStillWorking', 'wait', 'waitForTurn'), queueTooLarge: causeWords('queueTooLarge', 'actFirst', 'shrinkQueue'), // The rewind control words its own refusals; anywhere else says only that it did not happen. rewindRefused: codeWords('hostFinding'), diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index e7f609c0eb44..11081903bd33 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -4,6 +4,7 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import { AGENT_STARTING, AGENT_STILL_WORKING, + WAIT_FOR_TURN, BACKGROUND_TASKS_RUNNING, QUIT_TERMINAL_AGENT, START_NEW_CHAT, @@ -63,7 +64,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { agentStarting: AGENT_STARTING, waitForStart: WAIT_FOR_START, turnActive: 'The agent is still responding.', - waitForTurn: 'Wait for the agent to finish responding, or stop it.', + waitForTurn: WAIT_FOR_TURN, promptPending: 'The agent is waiting for an answer to a question or approval.', answerFirst: 'Answer the question or approval first.', backgroundTasksRunning: BACKGROUND_TASKS_RUNNING, From 109907aeacb5e88203202b886b1d31ae44462d78 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:04:37 -0700 Subject: [PATCH 10/16] Fix chat admission type imports and cover turn-scan costs --- .../structured-agent-session-adapter.ts | 2 +- ...t-session-claude-opening-send-hold.test.ts | 67 ++++++++++++++++++- ...uctured-agent-session-execution-options.ts | 6 -- ...structured-agent-session-outgrown-child.ts | 15 +++-- ...ctured-agent-session-prepared-admission.ts | 5 ++ .../agent-session-failure-words-text.test.ts | 3 +- .../agent-session-failure-words-text.ts | 2 + 7 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6ca11df3ea25..0f45baa2ad4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,4 +1,4 @@ -import type { StructuredAgentSessionPreparedAdmission } from './structured-agent-session-execution-options' +import type { StructuredAgentSessionPreparedAdmission } from './structured-agent-session-prepared-admission' import type { AiVaultConversationCopy } from '../../../shared/ai-vault-resume-preparation' import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability' import type { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 3d22e2423b35..246665742856 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -43,6 +43,18 @@ import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-u import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { runningStructuredAgentSessionTurnItemBySequence } from '../../../shared/structured-agent-session-live-turn' +import type * as LiveTurn from '../../../shared/structured-agent-session-live-turn' + +vi.mock('../../../shared/structured-agent-session-live-turn', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runningStructuredAgentSessionTurnItemBySequence: vi.fn( + actual.runningStructuredAgentSessionTurnItemBySequence + ) + } +}) vi.mock('./structured-agent-session-outgrown-child', async (importOriginal) => { const actual = await importOriginal() @@ -630,6 +642,7 @@ async function olderHostSends(count: number, answer?: 'accepted' | 'notSignedIn' * `newestTurn` reader there), and what the CLI holds, built by the adapter itself on each read. */ function countRetirementReads() { const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const runningScans: { phase: string; visits: number }[] = [] const counts = { checks: 0, linkageVisits: 0, @@ -638,19 +651,42 @@ function countRetirementReads() { heldSets: 0, heldVisits: 0, rejections: 0, + runningScans, afterCheck: async (_check: number): Promise => undefined } let inside = false + let phase: string | null = null + const running = vi.mocked(runningStructuredAgentSessionTurnItemBySequence) + const originalRunning = running.getMockImplementation()! + onTestFinished(() => void running.mockImplementation(originalRunning)) + running.mockImplementation((items) => { + const scan = phase ? { phase, visits: 0 } : null + if (scan) { + counts.runningScans.push(scan) + } + return originalRunning({ + *[Symbol.iterator]() { + for (const item of items) { + if (scan) { + scan.visits += 1 + } + yield item + } + } + }) + }) const retire = vi.mocked(retireOutgrownStructuredAgentSessionChild) const actual = retire.getMockImplementation()! onTestFinished(() => void retire.mockImplementation(actual)) retire.mockImplementation(async (...args) => { counts.checks += 1 inside = true + phase = `owner-${counts.checks}` try { return await actual(...args) } finally { inside = false + phase = counts.checks === 2 ? 'turnScope' : null await counts.afterCheck(counts.checks) } }) @@ -672,7 +708,22 @@ function countRetirementReads() { const resolveDispatch = journal.resolveDispatch.bind(journal) journal.resolveDispatch = (input, cards, receipt) => { counts.rejections += input.state === 'rejected' ? 1 : 0 - return resolveDispatch(input, cards, receipt) + phase = null + return resolveDispatch( + input, + cards, + receipt && { + ...receipt, + write: (...args) => { + phase = 'receipt.write' + try { + return receipt.write(...args) + } finally { + phase = null + } + } + } + ) } const { heldSends } = adapter adapter.heldSends = (sessionId) => { @@ -745,9 +796,23 @@ describe.each([20, 200])('with %i earlier sends', (count) => { await eventually(() => expect(host.currentWork(SESSION)?.activeTurnId()).not.toBeNull()) await pickPermission('ask') const counts = countRetirementReads() + const dispatch = vi.spyOn(adapter, 'dispatch') await send('STEER prompt') await eventually(() => expect(written(connection, 'STEER prompt')).toBeDefined()) expect(counts).toMatchObject({ checks: 2, newestTurn: 0 }) + const { items } = await snapshot() + expect(counts.runningScans).toEqual( + ['owner-1', 'owner-2', 'turnScope', 'receipt.write'].map((phase) => ({ + phase, + visits: items.length + })) + ) + const addedScans = counts.runningScans.filter(({ phase }) => phase !== 'turnScope') + expect(addedScans.reduce((total, scan) => total + scan.visits, 0)).toBe(3 * items.length) + expect(dispatch).toHaveBeenCalledTimes(1) + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('STEER prompt')) + ).toHaveLength(1) expect(connection.closeCount).toBe(0) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts index d3b9910b8dce..b26b3788bf51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -40,9 +40,3 @@ export function captureStructuredExecutionOptions( ) return refusal ? { refusal } : { options } } - -/** Local preparation only: no journal or wire representation. */ -export type StructuredAgentSessionPreparedAdmission = ( - offer: (requestedAt: number) => Promise, - currentTurn: () => string | null -) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 89177121589c..5f3944e39678 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -164,13 +164,14 @@ export async function retireOutgrownStructuredAgentSessionChild( deps.next?.queuedMessageId === undefined ? deps.work.currentWork()?.activeTurnId() : null - const steer = turnId - ? (providerTurn: () => string | null) => - session.child === child && - !child.close && - deps.work.currentWork()?.activeTurnId() === turnId && - providerTurn() === turnId - : undefined + const steer = + turnId && child + ? (providerTurn: () => string | null) => + session.child === child && + !child.close && + deps.work.currentWork()?.activeTurnId() === turnId && + providerTurn() === turnId + : undefined const keepOptions = mail || steer !== undefined const otherReason = structuredAgentSessionChildReportedSignedOut(session) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts new file mode 100644 index 000000000000..90def80c4b4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prepared-admission.ts @@ -0,0 +1,5 @@ +/** Local preparation only: no journal or wire representation. */ +export type StructuredAgentSessionPreparedAdmission = ( + offer: (requestedAt: number) => Promise, + currentTurn: () => string | null +) => Promise diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 75916328e7f2..44f260b08d7a 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -42,7 +42,8 @@ const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ 'waitForBackgroundTasks', 'agentStarting', 'waitForStart', - 'agentStillWorking' + 'agentStillWorking', + 'waitForTurn' ] // Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...). const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 29dd9d8567d9..801727430958 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -225,6 +225,8 @@ const PIECES: Record translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking), + waitForTurn: () => + translate('components.native-chat.writeNotice.waitForTurn', COPY.waitForTurn), runCommandWhenDone: (values) => translate( 'components.native-chat.failureWords.runCommandWhenDone', From a3dde711f0069dc94fed93dea99ab2a1a230007b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:09:47 -0700 Subject: [PATCH 11/16] Preserve root lifecycle delivery under journal pressure --- src/main/claude/claude-open-turn.ts | 2 +- src/main/claude/claude-terminal-failure.ts | 6 +- src/main/claude/claude-turn-lifecycle-item.ts | 2 +- .../agent-journal-turn-row-revision.ts | 2 +- ...agent-session-claude-root-pressure.test.ts | 261 ++++++++++++++++++ ...-agent-session-claude-subagent-row.test.ts | 1 - ...ructured-agent-session-event-sink-queue.ts | 25 +- ...tructured-agent-session-event-sink.test.ts | 188 +++++++++++++ .../structured-agent-session-event-sink.ts | 7 +- ...ude-structured-retired-saved-model.test.ts | 15 +- ...agent-session-codex-scripted-child.test.ts | 191 +++++++++++++ 11 files changed, 674 insertions(+), 26 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index de90e41dff09..1cd8f54e4b1b 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -241,6 +241,6 @@ export class ClaudeOpenTurn { options: item.options }) // Keyed apart, so this never replaces the start's publication while it still waits to run. - this.deps.sink.publish({ coalescingKey: item.publishCoalescingKey }) + this.deps.sink.publish({ criticalRoot: true, coalescingKey: item.publishCoalescingKey }) } } diff --git a/src/main/claude/claude-terminal-failure.ts b/src/main/claude/claude-terminal-failure.ts index 648e0bd9f2e6..cb5506e26fa5 100644 --- a/src/main/claude/claude-terminal-failure.ts +++ b/src/main/claude/claude-terminal-failure.ts @@ -27,11 +27,11 @@ export function writeClaudeTerminalFailure( ): void { const turnItemId = agentJournalItemKey(identity) const failureIdentity = claudeTerminalFailureIdentity(turnItemId) - const options = { turnScope: { kind: 'turn' as const, turnItemId } } + const options = { criticalRoot: true as const, turnScope: { kind: 'turn' as const, turnItemId } } const revise = sink.tryReviseResolvedItemAndPublish if (!revise) { sink.appendItem(failureIdentity, body, options) - sink.publish() + sink.publish({ criticalRoot: true }) return } const reservedBytes = Buffer.byteLength(JSON.stringify({ failureIdentity, body }), 'utf8') + 8192 @@ -117,7 +117,7 @@ export function refineClaudeQuotaTurnEnd( reservedBytes ) }, - { turnScope: { kind: 'thread' } } + { criticalRoot: true, turnScope: { kind: 'thread' } } ) return true } diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index be2433bcd15e..f0da28b05ce9 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -111,7 +111,7 @@ export function claudeTurnLifecycleItem( : { turnId, state: 'running', startedAt, ...requested, userItemId } ), // The running row's ts is the turn start itself, so clients read no append lag. - options: end ? {} : { observedAt: startedAt }, + options: { criticalRoot: true, ...(end ? {} : { observedAt: startedAt }) }, publishCoalescingKey: end ? 'publish' : `turn-start:${sessionId}:${turnId}` } } diff --git a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts index dfede8c46f7c..891d03644d89 100644 --- a/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts +++ b/src/main/native-chat/agent-session-timeline/agent-journal-turn-row-revision.ts @@ -210,7 +210,7 @@ export function writeAgentJournalTurnRow( if (write.lifecycle && 'identity' in target) { sink.appendItem(target.identity, write.lifecycle, options) if (publish) { - sink.publish() + sink.publish({ criticalRoot: delivery.criticalRoot }) } } return diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts new file mode 100644 index 000000000000..fb589d9de7e8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-pressure.test.ts @@ -0,0 +1,261 @@ +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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + claudeStartupSettled, + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import { claudeStructuredPermissionOptions } from '../../claude/claude-structured-launch-resolution' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { OrchestrationDb } from '../../runtime/orchestration/db' +import { structuredWorkerOwesWork } from '../../runtime/structured-worker-custody' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import type * as EventSink from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +vi.mock('./structured-agent-session-event-sink', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createDeferredStructuredAgentSessionEventSink: ( + deps: Parameters[0] + ) => + actual.createDeferredStructuredAgentSessionEventSink({ + ...deps, + watermarks: { + maxQueuedOperations: 4, + maxLifecycleQueuedOperations: 4, + pauseQueuedOperations: 2, + lowQueuedOperations: 1 + } + }) + } +}) + +let releaseBarriers: (() => void)[] = [] +let claudeFrames: { replayUuid: null; initProof?: 'none' } +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let records: ReturnType +let orchestration: OrchestrationDb +/** The host's clock, which stamps every child's start and end. */ +let clock = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-')) + clock = NOW + releaseBarriers = [] + resetHostTestOperationIds() + const log = recordingStructuredAgentSessionLogger() + claudeFrames = { replayUuid: null } + claude = fakeClaude(claudeFrames) + orchestration = new OrchestrationDb(':memory:') + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async ({ permissionMode }) => ({ + pathToClaudeCodeExecutable: 'claude', + options: claudeStructuredPermissionOptions(permissionMode ?? 'ask'), + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + // A later child resumes the transcript the first one started. + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: async () => undefined, + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence, producer) => + host.publishChildWorkEvidence(sessionId, evidence, producer), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => clock + }) + store = await openTestAgentSessionRecordStore(root) + records = acpChildWorkStatusSink() + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // The host's child records live in a real hook server's store, as in production. + statusSink: records.sink, + hasOpenDispatch: (record) => structuredWorkerOwesWork(orchestration, record), + now: () => clock + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + options: { model: 'haiku', permissionMode: 'auto' }, + 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 claudeStartupSettled(adapter, SESSION) +}) + +afterEach(async () => { + releaseBarriers.forEach((release) => release()) + setStructuredAgentSessionHost(null) + await adapter.closeAll() + await host.flushAllStreamedEvents() + orchestration.close() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: Fields) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +type Fields = Parameters[0]['fields'] + +async function sendAndEcho(connection: FakeConnection, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(sent.ok).toBe(true) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) + frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', model: 'claude-sonnet-5' }) + frame(connection, { ...connection.sent.at(-1)! }) +} + +it('retains the root end under pressure so the same input progresses once without closing', async () => { + const written = (on: FakeConnection, text: string) => + on.sent.find((message) => JSON.stringify(message).includes(text)) + const snapshot = () => host.journalSnapshot(SESSION) + const connection = claude.connections[0]! + await sendAndEcho(connection, 'A before pressure') + await host.flushStreamedEvents(SESSION) + const collaborators = host.collaboratorsForTests() + const journal = collaborators.sessions.get(SESSION)!.journal + const sink = collaborators.runtimeState.currentEventSink(SESSION)! + const target = host.currentWork(SESSION)!.activeTurnId() + expect(target).not.toBeNull() + for (let index = 0; index < 4; index += 1) { + frame(connection, { + type: 'stream_event', + uuid: `unfinished-thinking-${index}`, + parent_tool_use_id: null, + event: { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: 'x' } + } + }) + await host.flushStreamedEvents(SESSION) + } + const revision = vi.spyOn(sink.sink, 'tryReviseResolvedItem') + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + expect(sink.state()).toMatchObject({ backpressured: true, failed: false }) + expect(sink.state().queuedOperations).toBeGreaterThan(4) + expect(revision.mock.results.map(({ value }) => value)).toContainEqual({ accepted: true }) + await host.flushStreamedEvents(SESSION) + expect(host.currentWork(SESSION)?.activeTurnId()).toBeNull() + expect( + (await snapshot()).items + .map(({ body }) => readAgentJournalTurn(body)) + .find((turn) => turn?.turnId === target) + ).toMatchObject({ state: 'completed' }) + + // A mistakenly requested close cannot unblock the input in this fixture. + const releaseClose = Promise.withResolvers() + releaseBarriers.push(() => releaseClose.resolve()) + const close = connection.close.bind(connection) + const closing = vi.spyOn(connection, 'close').mockImplementation(async () => { + await releaseClose.promise + return close() + }) + const body = hostTestMessage('B after pressured end') + const capture = { model: 'haiku', effort: 'high' } + const sendEnvelope = envelope('agentSession.send', { body, options: capture }) + const bId = sendEnvelope.clientOperationId + const pending = vi.spyOn(journal, 'resolveDispatch') + const dispatch = vi.spyOn(adapter, 'dispatch') + expect(await host.send(CALLER, { envelope: sendEnvelope, body, options: capture })).toMatchObject( + { ok: true } + ) + await eventually(() => expect(written(connection, 'B after pressured end')).toBeDefined()) + const bInput = dispatch.mock.calls.filter(([input]) => input.clientMessageId === bId) + expect(bInput).toHaveLength(1) + expect(bInput[0]![0]).toMatchObject({ body, executionOptions: capture }) + expect( + pending.mock.calls.filter( + ([input]) => input.clientMessageId === bId && input.state === 'pending' + ) + ).toHaveLength(1) + expect(closing).not.toHaveBeenCalled() + expect(claude.connections).toHaveLength(1) + frame(connection, { + ...written(connection, 'B after pressured end')!, + isReplay: true, + parent_tool_use_id: null + }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + await host.flushStreamedEvents(SESSION) + await eventually(() => + expect(collaborators.conversationDelivery.loop.isRunning(SESSION)).toBe(false) + ) + expect(dispatch.mock.calls.filter(([input]) => input.clientMessageId === bId)).toHaveLength(1) + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('B after pressured end')) + ).toHaveLength(1) + expect( + (await snapshot()).submissions.find(({ clientMessageId }) => clientMessageId === bId) + ).toMatchObject({ + clientMessageId: bId, + options: capture, + dispatchState: 'accepted' + }) + expect(closing).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts index 9d5e24003e8b..a168bd048cc4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -51,7 +51,6 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } - let releaseBarriers: (() => void)[] = [] let claudeFrames: { replayUuid: null; initProof?: 'none' } let root: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 836cf594e462..0f7131173304 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -62,7 +62,7 @@ export class StructuredAgentSessionSinkQueue { private handedOverSequence = 0 /** Settles once every operation handed over so far has, in handover order. */ private handedOverSettled: Promise = Promise.resolve() - private readonly buffered: Admitted[] = [] + private readonly buffered = new Map() private readonly waitingPublications = new Map() private readonly waiters: StructuredAgentSessionDrainWaiter[] = [] private readonly finalTail = new StructuredAgentSessionFinalTailReservation() @@ -117,7 +117,9 @@ export class StructuredAgentSessionSinkQueue { return } this.target = target - for (const operation of this.buffered.splice(0)) { + const buffered = [...this.buffered.values()] + this.buffered.clear() + for (const operation of buffered) { this.handOver(operation, target) } } @@ -162,14 +164,8 @@ export class StructuredAgentSessionSinkQueue { return { accepted: false, reason: 'failed' } } const key = operation.publicationKey - const replaceAt = - key === undefined ? -1 : this.buffered.findIndex((queued) => queued.publicationKey === key) const replaced = - replaceAt >= 0 - ? this.buffered[replaceAt] - : key === undefined - ? undefined - : this.waitingPublications.get(key) + key === undefined ? undefined : (this.buffered.get(key) ?? this.waitingPublications.get(key)) const finalTail = operation.finalTail === true const lifecycle = !finalTail && (operation.lifecycle ?? options.lifecycle === true) const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 @@ -184,7 +180,7 @@ export class StructuredAgentSessionSinkQueue { !finalTail && !lifecycle && nextOperations > this.deps.watermarks.maxQueuedOperations const exceedsLifecycle = lifecycle && nextLifecycleOperations > this.deps.watermarks.maxLifecycleQueuedOperations - if (exceedsOrdinary || exceedsLifecycle) { + if (!options.criticalRoot && (exceedsOrdinary || exceedsLifecycle)) { this.setBackpressure(true) return { accepted: false, reason: 'backpressure' } } @@ -201,9 +197,7 @@ export class StructuredAgentSessionSinkQueue { lifecycleBytes, releaseFinalTail: releaseFinalTail ?? undefined } - if (replaceAt >= 0) { - this.buffered.splice(replaceAt, 1) - } else if (replaced) { + if (replaced && !this.buffered.delete(key ?? replaced.sequence)) { // Its turn still comes, and settles as nothing: the publication after it covers it. replaced.superseded = true } @@ -214,7 +208,7 @@ export class StructuredAgentSessionSinkQueue { if (this.target) { this.handOver(accepted, this.target) } else { - this.buffered.push(accepted) + this.buffered.set(key ?? accepted.sequence, accepted) } this.updateBackpressure() return { accepted: true } @@ -270,7 +264,8 @@ export class StructuredAgentSessionSinkQueue { /** Buffered operations never reached a journal. They count as settled once everything handed * over before them has, so a barrier never resolves ahead of a write still landing. */ private dropBuffered(): void { - const dropped = this.buffered.splice(0) + const dropped = [...this.buffered.values()] + this.buffered.clear() for (const operation of dropped) { this.release(operation) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 7c28cf3912e2..78422105ce98 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -18,6 +18,7 @@ import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget } from './structured-agent-session-event-sink' +import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { @@ -123,6 +124,193 @@ function target( } describe('deferred structured agent-session event sink', () => { + it('bounds buffered key lookup and snapshot work while preserving coalescing and drop order', async () => { + const measure = async (size: number) => { + const runs = new WeakSet() + const maps = new WeakSet() + const counts = { keys: 0, snapshot: 0, scanned: 0 } + const isOperation = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + 'run' in value && + typeof value.run === 'function' && + runs.has(value.run) + const keyed = (key: unknown): boolean => + typeof key === 'string' && key.startsWith('buffer-cost:') + const get = Map.prototype.get + const set = Map.prototype.set + const remove = Map.prototype.delete + const values = Map.prototype.values + const findIndex = Array.prototype.findIndex + const spies = [ + vi.spyOn(Map.prototype, 'get').mockImplementation(function ( + this: Map, + key: unknown + ) { + if (keyed(key) || maps.has(this)) { + maps.add(this) + counts.keys += 1 + } + return get.call(this, key) + }), + vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key: unknown, + value: unknown + ) { + if (isOperation(value) || maps.has(this)) { + maps.add(this) + counts.keys += 1 + } + return set.call(this, key, value) + }), + vi.spyOn(Map.prototype, 'delete').mockImplementation(function ( + this: Map, + key: unknown + ) { + if (keyed(key) || maps.has(this)) { + counts.keys += 1 + } + return remove.call(this, key) + }), + vi.spyOn(Map.prototype, 'values').mockImplementation(function ( + this: Map + ) { + if (maps.has(this)) { + counts.snapshot += this.size + } + return values.call(this) + }), + vi.spyOn(Array.prototype, 'findIndex').mockImplementation(function ( + this: unknown[], + predicate, + thisArg + ) { + return findIndex.call(this, (value, index, array) => { + if (isOperation(value)) { + counts.scanned += 1 + } + return predicate.call(thisArg, value, index, array) + }) + }) + ] + try { + const log: string[] = [] + const bound = target(7, []) + const reads: (() => void)[] = [] + vi.spyOn(bound.journal, 'readInOrder').mockImplementation( + (read: () => T): Promise => + new Promise((resolve) => reads.push(() => resolve(read()))) + ) + const queue = () => + new StructuredAgentSessionSinkQueue({ + watermarks: { + pauseQueuedBytes: 4, + maxQueuedBytes: 8, + lowQueuedBytes: 1, + pauseQueuedOperations: 4, + maxQueuedOperations: 8, + lowQueuedOperations: 1, + maxLifecycleQueuedBytes: 8, + maxLifecycleQueuedOperations: 8 + } + }) + const live = queue() + const dropped = queue() + const submit = ( + sink: StructuredAgentSessionSinkQueue, + label: string, + key?: string, + reenter?: () => void + ) => { + const run = () => { + log.push(label) + reenter?.() + } + runs.add(run) + expect( + sink.submit({ bytes: 1, publicationKey: key, run }, { criticalRoot: true }) + ).toEqual({ accepted: true }) + } + const fill = (sink: StructuredAgentSessionSinkQueue) => { + for (let index = 0; index < size; index += 1) { + submit(sink, `write:${index}`) + submit(sink, `old:${index}`, `buffer-cost:${index}`) + } + for (let index = 0; index < size; index += 1) { + submit(sink, `buffered:${index}`, `buffer-cost:${index}`) + } + expect(sink.state()).toMatchObject({ + queuedOperations: 2 * size, + queuedBytes: 2 * size, + backpressured: true, + failed: false + }) + } + fill(live) + // During bind, an issued write unbinds and buffers one further write. + submit(live, 'reentry', undefined, () => { + live.unbind() + submit(live, 'from-reentry') + }) + live.bind(bound) + expect(log).toEqual([ + ...Array.from({ length: size }, (_, index) => `write:${index}`), + 'reentry' + ]) + for (let index = 0; index < size; index += 1) { + submit(live, `latest:${index}`, `buffer-cost:${index}`) + } + live.bind(bound) + for (const read of reads) { + read() + } + await expect(live.barrier()).resolves.toEqual({ ok: true }) + await expect(live.written()).resolves.toEqual({ ok: true }) + expect(log).toEqual([ + ...Array.from({ length: size }, (_, index) => `write:${index}`), + 'reentry', + 'from-reentry', + ...Array.from({ length: size }, (_, index) => `latest:${index}`) + ]) + expect(live.state()).toEqual({ + queuedOperations: 0, + queuedBytes: 0, + backpressured: false, + failed: false + }) + fill(dropped) + const beforeDrop = [...log] + const written = dropped.written() + dropped.close() + dropped.bind(bound) + await expect(dropped.barrier()).resolves.toEqual({ ok: true }) + await expect(written).resolves.toMatchObject({ ok: false }) + expect(log).toEqual(beforeDrop) + expect(dropped.state()).toEqual({ + queuedOperations: 0, + queuedBytes: 0, + backpressured: false, + failed: false + }) + return counts + } finally { + for (const spy of spies) { + spy.mockRestore() + } + } + } + const small = await measure(200) + const large = await measure(2000) + const work = (count: typeof small) => count.keys + count.snapshot + count.scanned + console.info('buffer key/snapshot work', { small, large }) + expect(small.snapshot).toBe(5 * 200 + 2) + expect(large.snapshot).toBe(5 * 2000 + 2) + expect(work(small)).toBeLessThanOrEqual(64 * 200 + 32) + expect(work(large)).toBeLessThanOrEqual(64 * 2000 + 32) + expect(work(large)).toBeLessThanOrEqual(10 * work(small) + 32) + }) + it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 259ba66e4ca5..eff6a21cf4f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -52,13 +52,15 @@ export type StructuredAgentSessionSinkBarrier = { ok: true } | { ok: false; erro export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & { /** Marks a critical lifecycle operation for lifecycle barriers and diagnostics. */ lifecycle?: boolean + /** Root control events remain ordered even when ordinary input is paused. */ + criticalRoot?: true /** Host clock to stamp on the row instead of its append time. */ observedAt?: number } export type StructuredAgentSessionPublishOptions = Pick< StructuredAgentSessionAppendOptions, - 'lifecycle' + 'lifecycle' | 'criticalRoot' > & { /** A publication still waiting with this key is replaced by this one. */ coalescingKey?: string @@ -212,7 +214,6 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { onBackpressureChange?: (backpressured: boolean, state: StructuredAgentSessionSinkState) => void }): DeferredStructuredAgentSessionEventSink { const exit = new StructuredAgentSessionExitObservation() - const watermarks = { ...DEFAULT_WATERMARKS, ...deps.watermarks } const failed = (error: unknown): void => { deps.logger.error('writing provider events to the chat journal failed', { scope: 'journal-event-sink', @@ -222,7 +223,7 @@ export function createDeferredStructuredAgentSessionEventSink(deps: { deps.onFailed?.(error) } const queue = new StructuredAgentSessionSinkQueue({ - watermarks, + watermarks: { ...DEFAULT_WATERMARKS, ...deps.watermarks }, onFailed: failed, ...(deps.readingControl ? { readingControl: deps.readingControl } : {}), ...(deps.onBackpressureChange ? { onBackpressureChange: deps.onBackpressureChange } : {}) diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index 786e2157febe..16a72e77936b 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -66,7 +66,8 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { 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')) + const reply = modelNotFoundReply('reply-1') + claude.child(SESSION).handlers.onMessage?.(reply) await host.flushStreamedEvents(SESSION) expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ model: RETIRED, @@ -92,6 +93,18 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { ) const options = structuredAgentSessionSendSelection(reopened) expect(options).toEqual({ model: answer.current.model, effort: 'high' }) + // Source-derived CLI 2.1.302 failed root result; retry starts after it ends. + claude.child(SESSION).handlers.onMessage?.({ + type: 'result', + uuid: 'result-1', + session_id: claudeSessionIdForOrcaSession(SESSION), + parent_tool_use_id: null, + subtype: 'success', + is_error: true, + terminal_reason: 'api_error', + result: reply.message.content[0]!.text + }) + await host.flushStreamedEvents(SESSION) const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') const setModel = vi.spyOn(claude.child(SESSION).connection, 'setModel') const body = hostTestMessage('try the replacement') diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts index f31fdad35f4c..b3146e5a7c7e 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -1,8 +1,15 @@ import { mkdtemp, rm } from 'node:fs/promises' +import { readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { createCodexAppServerRecordDispatcher } from '../codex/codex-app-server-record-dispatch' +import { readCodexThreadId, readCodexTurnId } from '../codex/codex-structured-thread-facts' +import * as turnOptions from '../native-chat/agent-session-wire/structured-agent-session-message-options' +import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' +import { structuredMailSource } from './orchestration/structured-mail-source' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { HOST_TEST_SESSION, @@ -33,12 +40,196 @@ function envelope(method: string, fence: number, fields: Record } } afterEach(async () => { + vi.restoreAllMocks() await stopStructuredAgentSessionRuntime() if (root) { await rm(root, { recursive: true, force: true }) } }) +it('delivers current-choice mail during a recorded Codex turn while changed-choice input waits for completion', async () => { + // Recorded real-binary lifecycle, with a faked upstream provider; original null clientIds stay intact. + const frames: { case: string; method: string; params: Record }[] = readFileSync( + join(__dirname, '../codex/__fixtures__/codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((frame) => frame.case === '0.158.0-control') + const threadId = readCodexThreadId(frames[0]?.params)! + const turnId = readCodexTurnId(frames.find(({ method }) => method === 'turn/started')?.params)! + const oldChoices = { model: 'scripted-model', effort: 'low' } + const capture = { model: 'scripted-alt-model', effort: 'high' } + let starts = 0 + const child = codexScriptedChild({ + 'thread/start': () => ({ thread: { id: threadId }, model: oldChoices.model }), + 'turn/start': () => ({ turn: { id: ++starts === 1 ? turnId : 'scripted-next-turn' } }), + 'turn/steer': () => ({ turnId }), + 'model/list': () => ({ + data: [ + { + model: oldChoices.model, + isDefault: true, + supportedReasoningEfforts: [{ reasoningEffort: 'low' }] + }, + { model: capture.model, supportedReasoningEfforts: [{ reasoningEffort: 'high' }] } + ], + nextCursor: null + }) + }) + child.holdHandshakes = false + const open = child.deps.openCodexConnection! + let dispatcher: ReturnType | undefined + const { host, fence } = await attachedHost({ + ...child, + deps: { + ...child.deps, + ...scriptedCodexTransport(async (launch, handlers = {}) => { + dispatcher = createCodexAppServerRecordDispatcher({ + handlers, + writeResponse: () => {}, + onProtocolFailure: (error) => { + throw error + } + }) + return open(launch, handlers) + }) + } + }) + const dispatch = vi.spyOn(host.deps.adapter, 'dispatch') + const waits = vi.spyOn(turnOptions, 'waitsForCodexTurnOptions') + const requests = () => + child.requests().filter(({ method }) => method === 'turn/start' || method === 'turn/steer') + const a = hostTestMessage('A on current choices') + const aEnvelope = envelope('agentSession.send', fence, { body: a, options: oldChoices }) + expect( + await host.send(caller, { envelope: aEnvelope, body: a, options: oldChoices }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(requests()).toHaveLength(1)) + expect(requests()[0]).toMatchObject({ method: 'turn/start', params: oldChoices }) + const idleAt = frames.findIndex( + ({ method, params }) => + method === 'thread/status/changed' && JSON.stringify(params.status) === '{"type":"idle"}' + ) + for (const frame of frames.slice(0, 4)) { + dispatcher!.dispatch({ method: frame.method, params: frame.params }) + } + // SCRIPTED correlation only: these are not recorded mail/user echoes or rewritten capture frames. + const echo = (clientId: string, rootTurnId = turnId) => + dispatcher!.dispatch({ + method: 'item/completed', + params: { + threadId, + turn: { id: rootTurnId }, + item: { type: 'userMessage', id: `scripted-echo-${clientId}`, clientId, content: [] } + } + }) + echo(aEnvelope.clientOperationId) + await host.flushStreamedEvents(HOST_TEST_SESSION) + expect(host.currentWork(HOST_TEST_SESSION)?.activeTurnId()).toBe(turnId) + for (const [key, value] of Object.entries(capture)) { + expect( + await host.setOption(caller, { + envelope: envelope('agentSession.setOption', fence, { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } + const mail = hostTestMessage('worker mail on current choices') + mail.from = structuredMailSource({ + db: null, + mailboxHandle: 'dispatch:recorded-contract', + dispatchId: 'recorded-contract', + batch: [], + senderName: () => null + }) + const mailId = envelope('mail', fence, {}).clientOperationId + const sentMail = createStructuredMailboxPointerHost().send({ + sessionId: HOST_TEST_SESSION, + dispatchId: 'recorded-contract', + operationId: mailId, + expectedRuntimeFence: fence, + body: mail + }) + await vi.waitFor(() => expect(requests()).toHaveLength(2)) + expect(requests()[1]).toMatchObject({ + method: 'turn/steer', + params: { + expectedTurnId: turnId, + clientUserMessageId: mailId, + input: [{ type: 'text', text: 'worker mail on current choices' }] + } + }) + expect(requests()[1]?.params).not.toHaveProperty('model') + expect(requests()[1]?.params).not.toHaveProperty('effort') + const mailDispatch = dispatch.mock.calls.find(([input]) => input.clientMessageId === mailId)![0] + expect(mailDispatch.executionOptions).toBeUndefined() + expect( + waits.mock.calls.some( + ([, , next], index) => + next.clientMessageId === mailId && + next.options === undefined && + waits.mock.results[index]?.value === false + ) + ).toBe(true) + echo(mailId) + expect(await sentMail).toEqual({ kind: 'sent', state: 'accepted' }) + const b = hostTestMessage('ordinary B with captured choices') + const bEnvelope = envelope('agentSession.send', fence, { body: b, options: capture }) + const bId = bEnvelope.clientOperationId + expect(await host.send(caller, { envelope: bEnvelope, body: b, options: capture })).toMatchObject( + { ok: true } + ) + await vi.waitFor(() => + expect( + waits.mock.calls.some( + ([, , next], index) => + next.clientMessageId === bId && waits.mock.results[index]?.value === true + ) + ).toBe(true) + ) + for (const frame of frames.slice(4, idleAt + 1)) { + dispatcher!.dispatch({ method: frame.method, params: frame.params }) + } + await host.flushStreamedEvents(HOST_TEST_SESSION) + expect(host.currentWork(HOST_TEST_SESSION)?.activeTurnId()).toBe(turnId) + expect(requests()).toHaveLength(2) + for (const frame of frames.slice(idleAt + 1)) { + dispatcher!.dispatch({ method: frame.method, params: frame.params }) + } + await vi.waitFor(() => expect(requests()).toHaveLength(3)) + expect(requests()[2]).toMatchObject({ + method: 'turn/start', + params: { + ...capture, + clientUserMessageId: bId, + input: [{ type: 'text', text: 'ordinary B with captured choices' }] + } + }) + expect(dispatch.mock.calls.filter(([input]) => input.clientMessageId === bId)).toHaveLength(1) + expect( + dispatch.mock.calls.find(([input]) => input.clientMessageId === bId)![0].executionOptions + ).toEqual(capture) + echo(bId, 'scripted-next-turn') + await vi.waitFor(async () => { + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + expect(submissions.find(({ clientMessageId }) => clientMessageId === bId)).toMatchObject({ + options: capture, + dispatchState: 'accepted' + }) + expect(submissions.find(({ clientMessageId }) => clientMessageId === mailId)).toMatchObject({ + dispatchState: 'accepted' + }) + expect( + submissions.find(({ clientMessageId }) => clientMessageId === mailId)?.options + ).toBeUndefined() + }) + expect(child.spawns()).toBe(1) + expect(child.closes()).toBe(0) +}) + async function attachedHost(child: ScriptedAgentChild) { root = await mkdtemp(join(tmpdir(), 'orca-scripted-codex-')) const host = await ensureStructuredAgentSessionHost({ From 25c58eec511881e3f8128fa102a776ca063c5a4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:38:39 -0700 Subject: [PATCH 12/16] test: construct Codex mail fixture with its source --- ...-agent-session-codex-scripted-child.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts index b3146e5a7c7e..517457fae193 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -137,14 +137,16 @@ it('delivers current-choice mail during a recorded Codex turn while changed-choi }) ).toMatchObject({ ok: true }) } - const mail = hostTestMessage('worker mail on current choices') - mail.from = structuredMailSource({ - db: null, - mailboxHandle: 'dispatch:recorded-contract', - dispatchId: 'recorded-contract', - batch: [], - senderName: () => null - }) + const mail = { + ...hostTestMessage('worker mail on current choices'), + from: structuredMailSource({ + db: null, + mailboxHandle: 'dispatch:recorded-contract', + dispatchId: 'recorded-contract', + batch: [], + senderName: () => null + }) + } const mailId = envelope('mail', fence, {}).clientOperationId const sentMail = createStructuredMailboxPointerHost().send({ sessionId: HOST_TEST_SESSION, From f78425f2b88e65dff112e4910586f493eda9d870 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:51:21 -0700 Subject: [PATCH 13/16] test: expect root retention metadata in Claude turn timing --- .../claude-structured-journal-translation-turn-timing.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index 19a775230eca..f280fb0c6a14 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -161,7 +161,7 @@ describe('Claude structured turn timing', () => { state: 'running', startedAt: 1_000, userItemId: USER_1_KEY, - options: { observedAt: 1_000, turnScope: { kind: 'thread' } } + options: { criticalRoot: true, observedAt: 1_000, turnScope: { kind: 'thread' } } } ]) }) @@ -191,7 +191,7 @@ describe('Claude structured turn timing', () => { startedAt: 1_000, completedAt: 4_500, userItemId: USER_1_KEY, - options: { turnScope: { kind: 'thread' } } + options: { criticalRoot: true, turnScope: { kind: 'thread' } } }) expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) }) From efd720bdde98b8dfabd0bd19f6011b65589dbbce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:45:35 -0700 Subject: [PATCH 14/16] test: cover captured Claude full-ID Auto launch --- ...tured-agent-session-launch-permission.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index ed5f47969f2e..10010108b103 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -62,6 +62,22 @@ describe('the permission mode a chat starts with', () => { } ) + // Captured saved choices with an alias-only host catalog. + it('keeps saved Haiku full-ID Auto with the captured alias-only catalog', async () => { + const launch = launchPermission( + 'claude', + { model: 'claude-haiku-4-5-20251001', permissionMode: 'auto' }, + listing([ + { id: 'opus', isDefault: true, supportsAutoMode: true }, + { id: 'fable', isDefault: false, supportsAutoMode: true }, + { id: 'sonnet', isDefault: false, supportsAutoMode: true }, + { id: 'haiku', isDefault: false, supportsAutoMode: true } + ]) + ) + await expect(launch.result).resolves.toBe('auto') + expect(launch.read).not.toHaveBeenCalled() + }) + // #30032: a catalog saved under another account home read as none, and the chat relaunched asking. it('keeps saved auto with no selected model, unlisted models, or an unavailable catalog', async () => { const savedOptions: Record[] = [{}, { model: 'claude-opus-5-5[1m]' }] From 16235bf153fa8bb4d88f58171d7677dfcea8afd1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:51:14 -0700 Subject: [PATCH 15/16] test(native-chat): explain queued-send test timeout --- .../structured-agent-session-claude-opening-send-hold.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 5244ff9c79ad..4e040d077506 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -803,6 +803,7 @@ describe.each([20, 200])('with %i earlier sends', (count) => { expect(connection.closeCount).toBe(0) }) + // The 200-send case awaits journal writes and dispatches, so it has a larger test budget. it('refuses each queued model change behind an admitted send, reading what the CLI holds once per check', async () => { claudeFrames.initProof = 'none' const connection = claude.connections[0]! From 6a631feaa242ee81bf671f9db7a782cea30758fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:03:49 -0700 Subject: [PATCH 16/16] test: supply explicit mailbox working policy --- .../structured-agent-session-codex-scripted-child.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts index 3e6abc49372c..d92220b5787b 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -153,7 +153,8 @@ it('delivers current-choice mail during a recorded Codex turn while changed-choi dispatchId: 'recorded-contract', operationId: mailId, expectedRuntimeFence: fence, - body: mail + body: mail, + holdWhileWorking: false }) await vi.waitFor(() => expect(requests()).toHaveLength(2)) expect(requests()[1]).toMatchObject({