diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 1292da04427..4665238a2cd 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -2650,6 +2650,9 @@ "title": "Spracheingabe", "toggleMode": "Umschalten", "updateFailed": "Aktualisierung fehlgeschlagen" + }, + "agents": { + "permissionOwnAll": "Eigene Einstellungen der Agenten" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 9ac15e8be4b..fe11677af44 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -145,6 +145,9 @@ "speechModelHeading": "SPEECH MODEL", "speechModel": "Speech Model", "loading": "Loading voice settings…" + }, + "agents": { + "permissionOwnAll": "Agents' own settings" } }, "common": { diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 5cb8f1eeb13..82080992486 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -2721,6 +2721,9 @@ "title": "Voz", "toggleMode": "Alternar", "updateFailed": "No se pudo actualizar" + }, + "agents": { + "permissionOwnAll": "Configuración propia de los agentes" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index f42fa240c1b..50cfb043246 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -2721,6 +2721,9 @@ "title": "Voix", "toggleMode": "Bascule", "updateFailed": "Impossible de mettre à jour" + }, + "agents": { + "permissionOwnAll": "Paramètres propres aux agents" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 0f86c97b984..b0673eef17b 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -2646,6 +2646,9 @@ "title": "音声", "toggleMode": "トグル", "updateFailed": "更新できませんでした" + }, + "agents": { + "permissionOwnAll": "Agent 独自の設定" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 99d39517bc0..0517c56f328 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -2646,6 +2646,9 @@ "title": "음성", "toggleMode": "토글", "updateFailed": "업데이트하지 못했습니다." + }, + "agents": { + "permissionOwnAll": "에이전트 자체 설정" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index b86fc2de182..559a0eaad6c 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -2721,6 +2721,9 @@ "title": "Voz", "toggleMode": "Alternar", "updateFailed": "Não foi possível atualizar" + }, + "agents": { + "permissionOwnAll": "Configurações próprias dos agentes" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 0664fbaa435..f150095de10 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -2792,6 +2792,9 @@ "title": "Голос", "toggleMode": "Переключение", "updateFailed": "Не удалось обновить" + }, + "agents": { + "permissionOwnAll": "Собственные настройки агентов" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index 2c93fc79541..a3469517988 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -2650,6 +2650,9 @@ "title": "Ses", "toggleMode": "Aç/kapat", "updateFailed": "Güncellenemedi" + }, + "agents": { + "permissionOwnAll": "Ajanların kendi ayarları" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index a301d1cb98c..ecad99e81d9 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -2646,6 +2646,9 @@ "title": "語音", "toggleMode": "切換", "updateFailed": "無法更新" + }, + "agents": { + "permissionOwnAll": "各代理程式自身的設定" } }, "sourceControl": { diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 21fbe88459f..7aa183b20e0 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -2646,6 +2646,9 @@ "title": "语音", "toggleMode": "切换", "updateFailed": "无法更新" + }, + "agents": { + "permissionOwnAll": "各智能体自己的设置" } }, "sourceControl": { diff --git a/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts index d9b8c7e1552..aad9565ee6a 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionPickers.test.ts @@ -567,47 +567,52 @@ describe('MobileNativeChatSessionOptionPickers', () => { ['codex', 'full-access', 'Full access'], ['claude', 'plan', 'Plan mode'], ['codex', 'read-only', 'Read only'] - ])('uses the nullable %s/%s report without an Own row', async (agent, mode, label) => { - const state = createStructuredAgentSessionOptionState(agent, null) - const snapshot = (reportedPermissionMode?: string | null) => { - const row = structuredAgentSessionPermissionDescriptor({ - ...state, - permissionMode: 'own', - permissionOwnSupported: true, - reportedPermissionMode - }) - return row ? [row] : [] - } - const update = mount(snapshot()) - expect(pill('Permissions').props).toMatchObject({ - disabled: true, - accessibilityLabel: 'Permissions, Permissions' - }) - for (const report of [mode, null, mode, undefined]) { - update(false, snapshot(report)) + ])( + 'keeps Own selected despite the nullable %s/%s report without adding an Own row', + async (agent, mode) => { + const state = createStructuredAgentSessionOptionState(agent, null) + const snapshot = (reportedPermissionMode?: string | null) => { + const row = structuredAgentSessionPermissionDescriptor({ + ...state, + permissionMode: 'own', + permissionOwnSupported: true, + reportedPermissionMode + }) + return row ? [row] : [] + } + const update = mount(snapshot()) expect(pill('Permissions').props).toMatchObject({ - disabled: !report, - accessibilityLabel: `Permissions, ${report ? label : 'Permissions'}` + disabled: false, + accessibilityLabel: "Permissions, Agents' own settings" }) - } - update(false, snapshot(mode)) - await act(async () => pill('Permissions').props.onPress()) - const rows = findByHostType(renderer!.root, 'BottomDrawer').findAll( - (node) => node.props.accessibilityRole === 'radio' - ) - expect( - rows - .filter((row) => row.props.accessibilityState.checked) - .map((row) => findAllByHostType(row, 'Text')[0]?.props.children) - ).toEqual(['accept-edits', 'full-access'].includes(mode) ? [label] : []) - expect( - findAllByHostType(renderer!.root, 'Text').some((node) => - String(node.props.children).includes('own settings') + for (const report of [mode, null, mode, undefined]) { + update(false, snapshot(report)) + expect(pill('Permissions').props).toMatchObject({ + disabled: false, + accessibilityLabel: "Permissions, Agents' own settings" + }) + } + update(false, snapshot(mode)) + await act(async () => pill('Permissions').props.onPress()) + const rows = findByHostType(renderer!.root, 'BottomDrawer').findAll( + (node) => node.props.accessibilityRole === 'radio' ) - ).toBe(false) - await act(async () => rowByText('Full access').props.onPress()) - expect(setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access') - }) + expect( + rows + .filter((row) => row.props.accessibilityState.checked) + .map((row) => findAllByHostType(row, 'Text')[0]?.props.children) + ).toEqual([]) + expect( + rows.some((row) => + findAllByHostType(row, 'Text').some((node) => + String(node.props.children).includes('own settings') + ) + ) + ).toBe(false) + await act(async () => rowByText('Full access').props.onPress()) + expect(setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access') + } + ) it.each([ ['ask', 'Ask for approval', 'Hand', 'full-access', 'Full access'], diff --git a/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionRows.test.ts b/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionRows.test.ts index edc4b4873e6..2acde2abd41 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionRows.test.ts +++ b/mobile/src/session/native-chat/input/MobileNativeChatSessionOptionRows.test.ts @@ -1,3 +1,7 @@ +import { mobileSessionOptionSummaryValue } from './mobile-native-chat-session-option-labels' +import { structuredAgentSessionPermissionDescriptor } from '../../../../../src/shared/agent-session/options/structured-agent-session-permission-option' +import { createStructuredAgentSessionOptionState } from '../../../../../src/shared/agent-session/options/structured-agent-session-options' +import { activateMobileUiLocale } from '../../../i18n/mobile-i18n' import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' @@ -99,3 +103,21 @@ describe('DescriptorRows boolean', () => { expect(findByHostType(renderer.root, 'Switch').props.value).toBe(true) }) }) + +it('shows saved Own on the phone independently of reports, including translated output', async () => { + const descriptor = structuredAgentSessionPermissionDescriptor({ + ...createStructuredAgentSessionOptionState('codex', null), + permissionMode: 'own', + permissionOwnSupported: true, + reportedPermissionMode: 'full-access' + })! + expect(mobileSessionOptionSummaryValue(descriptor)).toBe("Agents' own settings") + try { + await activateMobileUiLocale('es') + const label = mobileSessionOptionSummaryValue(descriptor) + expect(label).not.toBe("Agents' own settings") + expect(label).not.toBe('own') + } finally { + await activateMobileUiLocale('en') + } +}) diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-session-option-labels.ts b/mobile/src/session/native-chat/input/mobile-native-chat-session-option-labels.ts index 7bb0b0663d2..40856e0880f 100644 --- a/mobile/src/session/native-chat/input/mobile-native-chat-session-option-labels.ts +++ b/mobile/src/session/native-chat/input/mobile-native-chat-session-option-labels.ts @@ -19,6 +19,13 @@ export function mobileSessionOptionSummaryValue(descriptor: SessionOptionDescrip ? translate('chat.sessionOptionCopy.permissions', 'Permissions') : translate('chat.sessionOptionCopy.notSet', 'Not set') } + if ( + descriptor.id === 'permissionMode' && + descriptor.kind.type === 'select' && + descriptor.kind.currentValue === 'own' + ) { + return translate('settings.agents.permissionOwnAll', "Agents' own settings") + } const choice = selectedNativeChatChoice(descriptor) if (!choice) { return translate('chat.sessionOptionCopy.notSet', 'Not set') 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 c4aedbefa90..c7dcf708b48 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 @@ -235,7 +235,7 @@ describe('the permission mode on the phone', () => { }) } const options = await mountOptions({ client, fence: 1, optionStamp: '', permissionOwn: true }) - expect(options.permission()).toBe('full-access') + expect(options.permission()).toBe('own') expect(options.offered()).not.toContain('own') await options.pick('full-access') expect(mutate).toHaveBeenCalledExactlyOnceWith( diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 90ccfdc0396..2850aaafa01 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -127,15 +127,17 @@ export class AcpStructuredLane { } /** Resolves when the turn leaves or further writes become impossible. */ - whenTurnLeaves(turnId: string): Promise { + whenTurnLeaves(turnId: string, signal?: AbortSignal): Promise { return new Promise((resolve) => { const check = (): void => { - if (this.failed || this.disposed || this.openTurnId !== turnId) { + if (signal?.aborted || this.failed || this.disposed || this.openTurnId !== turnId) { this.turnWatchers.delete(check) + signal?.removeEventListener('abort', check) resolve() } } this.turnWatchers.add(check) + signal?.addEventListener('abort', check, { once: true }) check() }) } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 96756794515..d9f8fd261ba 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -24,7 +24,7 @@ import { AcpConnectionClosedError } from './acp-errors' import { withProviderSpawnAbsence } from '../provider-process/provider-executable-missing' import { applyAcpMessageOptions } from './acp-message-execution-options' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' -import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' +import * as stop from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, @@ -39,6 +39,7 @@ import { AcpStructuredChildren } from './acp-structured-child-lifecycle' import { AcpConversationCopies } from './acp-conversation-copies' import { AcpTextTasks } from './acp-text-task' import { supportsAcpStructuredLocation } from './acp-structured-location-support' +import { admitStructuredAgentSessionInput } from '../native-chat/agent-session-wire/structured-agent-session-input-admission' export class AcpStructuredSessionAdapter implements SessionAdapter { readonly atRestCommands: AcpStructuredSessionAdapterDeps['atRestCommands'] @@ -117,7 +118,7 @@ export class AcpStructuredSessionAdapter implements SessionAdapter { const { connection } = attempt if (connection && error instanceof AcpConnectionClosedError) { // Wait briefly for process exit so startup failures include final diagnostics. - await waitForAcpExit(connection, this.stopGraceMs(), attempt.signal) + await waitForAcpExit(connection, this.graceMs(), attempt.signal) } // Capture spontaneous exit before close can classify it as requested. const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted @@ -208,11 +209,13 @@ export class AcpStructuredSessionAdapter implements SessionAdapter { input.signal?.throwIfAborted() await input.beforeDispatch?.() input.signal?.throwIfAborted() - session.turns.dispatch({ - clientMessageId: input.clientMessageId, - prompt, - requestedAt: input.requestedAt ?? this.now() - }) + await admitStructuredAgentSessionInput(input.admitFreshInput, () => + session.turns.dispatch({ + clientMessageId: input.clientMessageId, + prompt, + requestedAt: input.requestedAt ?? this.now() + }) + ) // Admission precedes provider confirmation of the send. return { state: 'admitted' } } @@ -231,16 +234,15 @@ export class AcpStructuredSessionAdapter implements SessionAdapter { return { cancelled: false, refusal: { turnNotRunning: true } } } // The host waits for turn completion or grace expiry, never for the cancel write. - interruptAcpTurn(session) + stop.interruptAcpTurn(session) return { cancelled: true } } - awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { - const session = this.children.sessions.get(sessionId) - if (session && childState.isAcpReadySession(session)) { - await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) - } - } + private readonly graceMs = (): number => this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + + awaitStoppedRequestEnd = stop.requestEndWait((id) => this.children.sessions.get(id), this.graceMs) + + settlePermissionTransition = stop.acpPermissionTransition((id) => this.live(id), this.graceMs) answerPrompt: SessionAdapter['answerPrompt'] = (input) => this.live(input.sessionId).prompts.answer(input) @@ -333,6 +335,5 @@ export class AcpStructuredSessionAdapter implements SessionAdapter { return session } - private readonly stopGraceMs = (): number => this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS private readonly now = (): number => (this.deps.now ?? Date.now)() } diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index ea382d7689b..58038bc072d 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -11,6 +11,8 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { awaitAcpTurnEnd } from './acp-structured-stop' +import { isAcpReadySession } from './acp-structured-child' import { openAcpAdapterRig, PROVIDER_SESSION, @@ -52,6 +54,123 @@ function answersCancels(agent: AcpScriptedAgent): void { }) } +describe('permission changes with already admitted ACP input', () => { + it('releases deadline and abort waiters without disturbing the active input', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'still running') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.settle() + const session = rig.adapter['children'].sessions.get(ADAPTER_SESSION) + if (!isAcpReadySession(session)) { + throw new Error('ACP session did not become ready') + } + for (const population of [1, 10]) { + for (let index = 0; index < population; index++) { + await awaitAcpTurnEnd(session, Date.now(), 0) + } + expect(session.turns['idleWaiters'].size).toBe(0) + expect(session.lane['turnWatchers'].size).toBe(0) + } + const abort = new AbortController() + const reason = new Error('caller stopped waiting') + const aborted = expect(awaitAcpTurnEnd(session, Date.now(), 10_000, abort.signal)).rejects.toBe( + reason + ) + abort.abort(reason) + await aborted + expect(session.turns['idleWaiters'].size).toBe(0) + expect(session.lane['turnWatchers'].size).toBe(0) + const successor = awaitAcpTurnEnd(session, Date.now(), 500) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await successor + expect(session.turns.running).toBe(false) + expect(session.lane.openTurnId).toBeNull() + }) + + it('settles a provider-initiated turn and delivers the new-policy input once', async () => { + const { host, rig } = await openAttachedHostRig() + const original = rig.child() + original.agent.notify('session/update', replyChunk(GROK_TURN, 'background task finished')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + original.agent.on('session/cancel', () => endsGrokTurn(original.agent)) + const fields = { key: 'permissionMode', value: 'full-access' } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true }) + const id = await send(host, 'new policy') + await waitFor(() => expect(original.exited).toBe(true)) + expect(framesOf(original, 'session/cancel')).toHaveLength(1) + expect(framesOf(original, 'session/prompt')).toHaveLength(0) + const prompt = await rig.frame('session/prompt') + expect(promptIdOf(prompt)).toBe(`prompt:${id}`) + rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'done')) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await waitFor(() => expect(journal.submission(id)?.dispatchState).toBe('accepted')) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it.each(['drained', 'deadline'] as const)( + 'preserves the earlier steer when the transition is %s', + async (outcome) => { + const fixture = await openAttachedHostRig({ stopGraceMs: outcome === 'deadline' ? 30 : 500 }) + const { host, rig } = fixture + const original = rig.child() + const a = await send(host, 'A running') + const promptA = await rig.frame('session/prompt') + original.agent.notify('session/update', replyChunk(promptIdOf(promptA), 'working A')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + const b = await send(host, 'B already admitted') + await waitFor(() => expect(framesOf(original, 'session/cancel')).toHaveLength(1)) + const fields = { key: 'permissionMode', value: 'full-access' } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true }) + const c = await send(host, 'C new policy') + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + if (outcome === 'deadline') { + await waitFor(() => expect(journal.submission(c)?.dispatchState).toBe('rejected')) + expect(journal.submission(c)?.rejection).toMatchObject({ + kind: 'restartFailed', + neverReachedAgent: true + }) + expect(journal.submission(b)?.dispatchState).toBe('pending') + expect(original.exited).toBe(false) + } + original.agent.reply(promptA, { stopReason: 'cancelled' }) + const promptB = await rig.frame('session/prompt', 1) + expect(promptIdOf(promptB)).toBe(`prompt:${b}`) + original.agent.notify('session/update', replyChunk(promptIdOf(promptB), 'answered B')) + original.agent.reply(promptB, { stopReason: 'end_turn' }) + await waitFor(() => expect(journal.submission(b)?.dispatchState).toBe('accepted')) + expect(journal.submission(a)?.dispatchState).toBe('accepted') + expect(framesOf(original, 'session/prompt')).toHaveLength(2) + if (outcome === 'drained') { + await waitFor(() => expect(original.exited).toBe(true)) + const promptC = await rig.frame('session/prompt') + expect(promptIdOf(promptC)).toBe(`prompt:${c}`) + rig.child().agent.notify('session/update', replyChunk(promptIdOf(promptC), 'answered C')) + rig.child().agent.reply(promptC, { stopReason: 'end_turn' }) + await waitFor(() => expect(journal.submission(c)?.dispatchState).toBe('accepted')) + } else { + expect(journal.submission(c)?.dispatchState).toBe('rejected') + } + await host.close(SESSION, 'user-close') + } + ) +}) + describe('a send while a Grok prompt runs', () => { it('cancels the running prompt, then sends as the next one', async () => { const rig = await openAcpAdapterRig() diff --git a/src/main/acp/acp-structured-stop.ts b/src/main/acp/acp-structured-stop.ts index 0984d40b676..b519768016e 100644 --- a/src/main/acp/acp-structured-stop.ts +++ b/src/main/acp/acp-structured-stop.ts @@ -1,3 +1,6 @@ +import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AcpStructuredSession } from './acp-structured-session' type AcpStoppableSession = Pick @@ -20,23 +23,29 @@ export function interruptAcpTurn(session: AcpStoppableSession): void { export async function awaitAcpTurnEnd( session: AcpStoppableSession, stoppedAt: number, - graceMs: number + graceMs: number, + signal?: AbortSignal ): Promise { + const waiting = new AbortController() let timer: ReturnType | undefined const elapsed = new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, stoppedAt + graceMs - Date.now())) }) const turnId = session.lane.openTurnId try { - await Promise.race([ - Promise.all([ - session.turns.whenIdle(), - turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) + await waitForPromiseWithSignal( + Promise.race([ + Promise.all([ + session.turns.whenIdle(waiting.signal), + turnId === null ? undefined : session.lane.whenTurnLeaves(turnId, waiting.signal) + ]), + elapsed ]), - elapsed - ]) + signal + ) } finally { clearTimeout(timer) + waiting.abort() } } @@ -54,3 +63,41 @@ export async function windDownAcpTurn( } await awaitAcpTurnEnd(session, turns.stoppedAt ?? Date.now(), graceMs) } + +export async function settleAcpPermissionTransition( + session: AcpStructuredSession, + input: Parameters>[0], + graceMs: number +): Promise { + input.signal?.throwIfAborted() + if (session.fence !== input.fence || session.closeRequested) { + return false + } + session.turns.interruptForPermissionTransition() + await awaitAcpTurnEnd(session, Date.now(), graceMs, input.signal) + return ( + !session.ended && + !session.closeRequested && + !session.turns.running && + session.lane.openTurnId === null + ) +} + +export function requestEndWait( + sessionFor: (id: string) => AcpStructuredChild | undefined, + graceMs: () => number +): NonNullable { + return async (id, at) => { + const session = sessionFor(id) + if (isAcpReadySession(session)) { + await awaitAcpTurnEnd(session, at, graceMs()) + } + } +} + +export function acpPermissionTransition( + sessionFor: (id: string) => AcpStructuredSession, + graceMs: () => number +): NonNullable { + return (input) => settleAcpPermissionTransition(sessionFor(input.sessionId), input, graceMs()) +} diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index 6f339101d66..38f5070820b 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -92,11 +92,29 @@ export class AcpStructuredTurns { return this.withdrawSteers() } - whenIdle(): Promise { - if (!this.active) { + /** Earlier admitted steers retain their native queue and settlement while the host holds new input. */ + interruptForPermissionTransition(): void { + if (this.active) { + this.cancelForSteer() + } else if (this.deps.lane.openTurnId !== null) { + this.deps.withdrawRequests() + void this.deps.connection.cancel().catch(() => undefined) + } + } + + whenIdle(signal?: AbortSignal): Promise { + if (!this.active || signal?.aborted) { return Promise.resolve() } - return new Promise((resolve) => this.idleWaiters.add(resolve)) + return new Promise((resolve) => { + const wake = (): void => { + this.idleWaiters.delete(wake) + signal?.removeEventListener('abort', wake) + resolve() + } + this.idleWaiters.add(wake) + signal?.addEventListener('abort', wake, { once: true }) + }) } dispatch(send: Send): void { diff --git a/src/main/claude/structured/claude-structured-dispatch.ts b/src/main/claude/structured/claude-structured-dispatch.ts index befd29270d0..ae453b44c09 100644 --- a/src/main/claude/structured/claude-structured-dispatch.ts +++ b/src/main/claude/structured/claude-structured-dispatch.ts @@ -37,6 +37,11 @@ import { isAgentSessionOptionRejectedError } from '../../native-chat/agent-session-wire/structured-agent-session-option-error' import { ClaudeControlRequestError } from '../claude-agent-sdk-control-requests' +import { + admitStructuredAgentSessionInput, + StructuredAgentSessionInputNotAdmitted, + type StructuredAgentSessionAdmitFreshInput +} from '../../native-chat/agent-session-wire/structured-agent-session-input-admission' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -124,6 +129,7 @@ export async function dispatchClaudeTurn( requestedAt?: number sentUuid?: string signal?: AbortSignal + admitFreshInput?: StructuredAgentSessionAdmitFreshInput }, beforeDispatch?: () => Promise ): Promise { @@ -170,10 +176,12 @@ export async function dispatchClaudeTurn( const authorize = beforeDispatch ? async () => { await beforeDispatch() - if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { - throw claudeUnwrittenUserMessageError(new Error(DISPATCH_REJECTED_QUEUE_FULL)) - } - pending.replay = arm() + await admitStructuredAgentSessionInput(input.admitFreshInput, () => { + if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { + throw claudeUnwrittenUserMessageError(new Error(DISPATCH_REJECTED_QUEUE_FULL)) + } + pending.replay = arm() + }) } : undefined try { @@ -186,6 +194,7 @@ export async function dispatchClaudeTurn( // Preserve pre-dispatch aborts for the host's Stop settlement. if ( error instanceof AgentSessionPreDispatchError || + error instanceof StructuredAgentSessionInputNotAdmitted || isAbortSignalReason(input.signal, error) ) { throw error diff --git a/src/main/claude/structured/launch/claude-structured-message-options.test.ts b/src/main/claude/structured/launch/claude-structured-message-options.test.ts index d8b37eaa408..8c9a5b36da5 100644 --- a/src/main/claude/structured/launch/claude-structured-message-options.test.ts +++ b/src/main/claude/structured/launch/claude-structured-message-options.test.ts @@ -1,3 +1,4 @@ +import { StructuredAgentSessionInputNotAdmitted } from '../../../native-chat/agent-session-wire/structured-agent-session-input-admission' import { describe, expect, it, vi } from 'vitest' import { acquired, @@ -35,6 +36,44 @@ const MODELS = [ } ] +it('checks the latest permission after model preparation without admitting a stale frame', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'permission-race', + events: recordingJournalSink() + }) + const connection = claude.connections[0]! + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-sonnet-5' }) + const preparing = Promise.withResolvers() + const resume = Promise.withResolvers() + connection.setModel = async () => { + preparing.resolve() + await resume.promise + } + let changed = false + const admitted = vi.fn() + const sending = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'permission-race', + body: USER_MESSAGE, + executionOptions: { model: 'custom[1m]' }, + fence: 7, + admitFreshInput: async (enqueue) => + changed ? { kind: 'acquire', reason: 'permission' } : enqueue('ask', false) + }) + const result = sending.then(admitted, (error) => error) + await preparing.promise + changed = true + resume.resolve() + expect(await result).toBeInstanceOf(StructuredAgentSessionInputNotAdmitted) + expect(admitted).not.toHaveBeenCalled() + expect(connection.sent).toHaveLength(0) + await adapter.closeAll() +}) + describe('Claude accepted message choices', () => { it.each([false, true])( 'delivers resumed choices despite optional reports (alias listed: %s)', diff --git a/src/main/claude/structured/launch/claude-structured-message-options.ts b/src/main/claude/structured/launch/claude-structured-message-options.ts index f9495d28312..bcb6006c3c9 100644 --- a/src/main/claude/structured/launch/claude-structured-message-options.ts +++ b/src/main/claude/structured/launch/claude-structured-message-options.ts @@ -4,6 +4,8 @@ import { dispatchClaudeTurn } from '../claude-structured-dispatch' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AgentSessionExecutionOptions } from '../../../../shared/agent-session/options/agent-session-execution-options' import { AgentSessionOptionRejectedError } from '../../../native-chat/agent-session-wire/structured-agent-session-option-error' +import { StructuredAgentSessionInputNotAdmitted } from '../../../native-chat/agent-session-wire/structured-agent-session-input-admission' +import { AgentSessionPreDispatchError } from '../../../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { createStructuredAgentSessionLogger, neverThrowingStructuredAgentSessionLogger, @@ -67,7 +69,6 @@ export async function dispatchClaudeMessage( ): ReturnType { let ranOptions: Record = {} const outcome = await dispatchClaudeTurn(session, input, async () => { - await input.beforeDispatch?.() try { input.signal?.throwIfAborted() // Input joining a running turn, and Orca mail notices, run on the choices Claude already has; @@ -84,6 +85,7 @@ export async function dispatchClaudeMessage( await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) } input.signal?.throwIfAborted() + await input.beforeDispatch?.() input.onExecutionSnapshot?.() ranOptions = claudeExecutionObservations(session) if (messageOptions?.model && ranOptions.model !== messageOptions.model) { @@ -100,6 +102,8 @@ export async function dispatchClaudeMessage( } ) throw error instanceof AgentSessionOptionRejectedError || + error instanceof AgentSessionPreDispatchError || + error instanceof StructuredAgentSessionInputNotAdmitted || isAbortSignalReason(input.signal, error) ? error : new AgentSessionOptionRejectedError(error) diff --git a/src/main/codex/codex-structured-dispatch-authority.ts b/src/main/codex/codex-structured-dispatch-authority.ts new file mode 100644 index 00000000000..7ab52e0cda4 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-authority.ts @@ -0,0 +1,28 @@ +import type { CodexTurnHost } from './codex-structured-turn-start' +import { StructuredAgentSessionInputNotAdmitted } from '../native-chat/agent-session-wire/structured-agent-session-input-admission' +import { codexDispatchRejection } from './codex-structured-turn-end-settlement' +import { agentSessionFailureFact } from '../../shared/agent-session/failures/agent-session-failure' + +export function checkDispatchAuthority( + host: CodexTurnHost, + id: string, + signal?: AbortSignal, + isCurrent?: () => boolean +): void { + if (isCurrent?.() === false) { + host.dispatchEchoes.disarm(id) + throw new StructuredAgentSessionInputNotAdmitted({ + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerExited', { neverReachedAgent: true }) + ) + }) + } + if (signal?.aborted) { + host.dispatchEchoes.disarm(id) + throw new StructuredAgentSessionInputNotAdmitted({ + state: 'rejected', + ...codexDispatchRejection(agentSessionFailureFact('cancelled')) + }) + } +} diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index eb9e055633e..1d32b71f0d2 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import * as permission from './codex-structured-turn-permission' import { projectCodexSessionOptions } from './codex-structured-service-tier' import * as codexRewind from './codex-structured-rewind' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session/agent-session-background-task-wire' @@ -95,7 +96,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap invocation: this.deps.renameInvocation }) - permissionModeRequiresAcquisition = (): boolean => false + permissionModeRequiresAcquisition = permission.codexNativeResumeRequirement(this.sessions) + + settlePermissionTransition = permission.createCodexPermissionTransition(this.sessions, this) acquire = (input: StructuredAgentSessionAcquireInput): Promise => acquireCodexStructuredSession({ diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 8c74bfc0ac5..5573bf8ff98 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -150,6 +150,7 @@ export type CodexSession = { /** Provider facts from this acquisition, not another copy of the chat's selected mode. */ permissions?: { acquiredMode: AgentChatPermissionChoice + appliedMode?: AgentChatPermissionChoice acquiredSandbox?: CodexSandboxPolicy launchSandboxIntact: boolean cwd: string diff --git a/src/main/codex/codex-structured-turn-permission.test.ts b/src/main/codex/codex-structured-turn-permission.test.ts index 50a9812156c..0d317079f86 100644 --- a/src/main/codex/codex-structured-turn-permission.test.ts +++ b/src/main/codex/codex-structured-turn-permission.test.ts @@ -145,7 +145,7 @@ it.each([null])('omits own policy on fresh input after opening %s', async (resum expect((await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current).toMatchObject( { reportedPermissionMode: 'ask' } ) - for (const [index, choice] of ['own', 'full-access', 'own'].entries()) { + for (const [index, choice] of ['own', 'full-access'].entries()) { mode = choice === 'own' ? 'own' : 'full-access' codex.routes['turn/start'] = answerWithOpenedTurn(codex, `own-${index}`) expect(await send(`original-${index}`)).toMatchObject({ state: 'admitted' }) @@ -174,8 +174,33 @@ it.each([null])('omits own policy on fresh input after opening %s', async (resum expect(opened).not.toHaveProperty('approvalPolicy') expect(opened).not.toHaveProperty('approvalsReviewer') expect(opened).not.toHaveProperty('sandbox') - expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(3) + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(2) expect(codex.connections).toHaveLength(1) + mode = 'own' + await expect(send('delegated-after-explicit')).rejects.toMatchObject({ reason: 'permission' }) + expect(adapter.permissionModeRequiresAcquisition('session-1', 'own')).toBe(true) + await adapter.closeSession('session-1') + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 8, + spawnToken: 'delegated-child', + permissionMode: 'own' + }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'delegated-resume') + expect( + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'delegated-resume', + fence: 8, + body: USER_MESSAGE, + admitFreshInput: async (enqueue) => enqueue('own', false) + }) + ).toMatchObject({ state: 'admitted' }) + const resumed = codex.connections + .at(-1)! + .calls.findLast((call) => call.method === 'turn/start')!.params! + expect(resumed).not.toHaveProperty('approvalPolicy') + expect(resumed).not.toHaveProperty('sandboxPolicy') } finally { await adapter.closeAll() } diff --git a/src/main/codex/codex-structured-turn-permission.ts b/src/main/codex/codex-structured-turn-permission.ts index 7b058be79ae..7aa40ebb456 100644 --- a/src/main/codex/codex-structured-turn-permission.ts +++ b/src/main/codex/codex-structured-turn-permission.ts @@ -1,3 +1,5 @@ +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { codexStopTarget } from './codex-structured-turn-open-wait' import type { AgentJournalMessageItem } from '../../shared/agent-session/journal/agent-session-journal-types' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { @@ -52,8 +54,7 @@ export function codexPermissionRequiresAcquisition( export function codexTurnPermission( session: PermissionHost, - mode: AgentChatPermissionChoice, - inheritRunning = false + mode: AgentChatPermissionChoice ): { approvalPolicy?: string; approvalsReviewer?: string; sandboxPolicy?: CodexSandboxPolicy } { if (mode === 'own') { return {} @@ -66,11 +67,7 @@ export function codexTurnPermission( : sandbox === 'danger-full-access' ? { type: 'dangerFullAccess' } : state?.preparedAuto - // Unknown Auto retains the running child's grant until safe reacquisition; never invent access. if (mode === 'auto' && codexPermissionRequiresAcquisition(session, mode)) { - if (inheritRunning) { - return {} - } throw new Error('Auto policy requires acquisition') } if (state && sandboxPolicy && mode !== state.acquiredMode) { @@ -85,23 +82,31 @@ export async function admitCodexTurnPermission( admit: StructuredAgentSessionAdmitFreshInput | undefined, write: ( permission: ReturnType, - body?: AgentJournalMessageItem + body?: AgentJournalMessageItem, + mode?: AgentChatPermissionChoice ) => Extract, signal?: AbortSignal ): Promise { let prepared = false const enqueue: Parameters[0] = ( mode, - inheritRunning, + _inheritRunning, body ) => { if (mode === 'auto' && !prepared) { return { kind: 'prepare' } } - if (codexPermissionRequiresAcquisition(session, mode) && !inheritRunning) { - return { kind: 'acquire' } + if ( + mode === 'own' && + session.permissions && + (session.permissions.appliedMode ?? session.permissions.acquiredMode) !== 'own' + ) { + return { kind: 'acquire', reason: 'permission' } } - return write(codexTurnPermission(session, mode, inheritRunning), body) + if (codexPermissionRequiresAcquisition(session, mode)) { + return { kind: 'acquire', reason: 'auto' } + } + return write(codexTurnPermission(session, mode), body, mode) } for (;;) { const admission = admit @@ -113,8 +118,59 @@ export async function admitCodexTurnPermission( continue } if (admission.kind === 'acquire') { - throw new StructuredAgentSessionInputNotAdmitted() + throw new StructuredAgentSessionInputNotAdmitted(undefined, admission.reason) } return admission.response } } + +export function codexNativeResumeRequirement( + sessions: ReadonlyMap +): NonNullable { + return (sessionId, mode) => { + const state = sessions.get(sessionId)?.permissions + return mode === 'own' && !!state && (state.appliedMode ?? state.acquiredMode) !== 'own' + } +} + +export function createCodexPermissionTransition( + sessions: ReadonlyMap, + controls: { + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] + awaitStoppedRequestEnd: (sessionId: string, stoppedAt: number) => Promise + } +): NonNullable { + return async (input) => { + const session = sessions.get(input.sessionId) + if (!session || session.ended || session.fence !== input.fence) { + return false + } + if ( + (session.permissions?.appliedMode ?? session.permissions?.acquiredMode) === input.mode || + codexStopTarget(session) === null + ) { + return true + } + const stoppedAt = Date.now() + const result = await waitForPromiseWithSignal( + controls.cancelTurn({ + sessionId: input.sessionId, + fence: input.fence, + stoppedAt + }), + input.signal + ) + if (!result.cancelled && codexStopTarget(session) !== null) { + return false + } + await waitForPromiseWithSignal( + controls.awaitStoppedRequestEnd(input.sessionId, stoppedAt), + input.signal + ) + return ( + sessions.get(input.sessionId) === session && + !session.ended && + codexStopTarget(session) === null + ) + } +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 12e738b231a..07b997790a2 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,3 +1,5 @@ +import { steerCodexTurn } from './codex-structured-turn-steer' +import { checkDispatchAuthority } from './codex-structured-dispatch-authority' import { StructuredAgentSessionInputNotAdmitted, type StructuredAgentSessionAdmitFreshInput @@ -25,6 +27,7 @@ import { codexTurnRequestOptions } from './codex-structured-service-tier' import type { CodexSession } from './codex-structured-session-state' import { codexRunningOrOpeningTurn, + codexStopTarget, type CodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { @@ -33,30 +36,6 @@ import { codexTurnEndRejection } from './codex-structured-turn-end-settlement' -function checkDispatchAuthority( - host: CodexTurnHost, - id: string, - signal?: AbortSignal, - isCurrent?: () => boolean -): void { - if (isCurrent?.() === false) { - host.dispatchEchoes.disarm(id) - throw new StructuredAgentSessionInputNotAdmitted({ - state: 'rejected', - ...codexDispatchRejection( - agentSessionFailureFact('providerExited', { neverReachedAgent: true }) - ) - }) - } - if (signal?.aborted) { - host.dispatchEchoes.disarm(id) - throw new StructuredAgentSessionInputNotAdmitted({ - state: 'rejected', - ...codexDispatchRejection(agentSessionFailureFact('cancelled')) - }) - } -} - export type CodexTurnHost = { account?: AgentSessionAccountKind skillReport?: Pick @@ -75,40 +54,6 @@ export type CodexTurnHost = { turnOpenWaits: Pick } -/** Explicit steering avoids pre-0.148 turn/start replies naming nonexistent turns; overrides wait for a start. */ -async function steerCodexTurn( - host: CodexTurnHost, - expectedTurnId: string, - input: { - clientMessageId: string - body: AgentJournalMessageItem - timeoutMs?: number - signal?: AbortSignal - isCurrent?: () => boolean - onReply?: () => void - } -): Promise<{ turnId: string; via: 'steer' } | null> { - try { - checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) - const answer = await host.connection.request( - 'turn/steer', - { - threadId: host.threadId, - expectedTurnId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(host, input.body) - }, - { timeoutMs: input.timeoutMs, onReply: input.onReply } - ) - return { turnId: readCodexTurnId(answer) ?? expectedTurnId, via: 'steer' } - } catch (error) { - if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { - return null - } - throw error - } -} - /** false means correlation capacity refused the send before writing; null turnId means an unnamed answer. */ export async function startCodexTurn( host: CodexTurnHost, @@ -155,9 +100,19 @@ export async function startCodexTurn( let discardSettingsAttempt: (() => void) | undefined const enqueue = ( permission: Parameters[2]>[0], - body = input.body + body = input.body, + mode = host.permissions?.acquiredMode ) => { checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) + // turn/start can steer too; a refused steer must not bypass the final policy boundary. + if ( + host.permissions && + mode && + mode !== (host.permissions.appliedMode ?? host.permissions.acquiredMode) && + codexStopTarget(host) !== null + ) { + throw new StructuredAgentSessionInputNotAdmitted(undefined, 'permission') + } previousApplied = host.appliedTurnOptions previousOptions = input.turnOptions ? new Map(host.options) : null if (input.turnOptions) { @@ -174,6 +129,10 @@ export async function startCodexTurn( host.appliedTurnOptions = known discardSettingsAttempt = host.appliedSettings?.attempt(requested) enqueued = true + const previousMode = host.permissions?.appliedMode + if (host.permissions && mode) { + host.permissions.appliedMode = mode + } return { kind: 'sent' as const, response: host.connection @@ -198,6 +157,15 @@ export async function startCodexTurn( } } ) + .catch((error: unknown) => { + if ( + host.permissions && + (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) + ) { + host.permissions.appliedMode = previousMode + } + throw error + }) .then((answer) => { const applied = reportedCodexPermissionMode({ ...permission, diff --git a/src/main/codex/codex-structured-turn-steer.ts b/src/main/codex/codex-structured-turn-steer.ts new file mode 100644 index 00000000000..1e6fa2e0f53 --- /dev/null +++ b/src/main/codex/codex-structured-turn-steer.ts @@ -0,0 +1,67 @@ +import { + StructuredAgentSessionInputNotAdmitted, + type StructuredAgentSessionAdmitFreshInput +} from '../native-chat/agent-session-wire/structured-agent-session-input-admission' +import type { CodexTurnHost } from './codex-structured-turn-start' +import type { AgentJournalMessageItem } from '../../shared/agent-session/journal/agent-session-journal-types' +import { checkDispatchAuthority } from './codex-structured-dispatch-authority' +import { turnInputFor } from './codex-structured-message-options' +import { readCodexTurnId } from './codex-structured-thread-facts' +import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error' +import { isCodexAppServerUnsupportedError } from './app-server/codex-app-server-session' + +/** Explicit steering avoids pre-0.148 turn/start replies naming nonexistent turns; overrides wait for a start. */ +export async function steerCodexTurn( + host: CodexTurnHost, + expectedTurnId: string, + input: { + clientMessageId: string + body: AgentJournalMessageItem + timeoutMs?: number + signal?: AbortSignal + isCurrent?: () => boolean + onReply?: () => void + admitFreshInput?: StructuredAgentSessionAdmitFreshInput + } +): Promise<{ turnId: string; via: 'steer' } | null> { + try { + checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) + const enqueue = (mode: Parameters[0]>[0]) => { + const state = host.permissions + if (state && mode !== (state.appliedMode ?? state.acquiredMode)) { + return { kind: 'acquire' as const, reason: 'permission' as const } + } + checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) + return { + kind: 'sent' as const, + response: host.connection.request( + 'turn/steer', + { + threadId: host.threadId, + expectedTurnId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(host, input.body) + }, + { timeoutMs: input.timeoutMs, onReply: input.onReply } + ) + } + } + const admission = input.admitFreshInput + ? await input.admitFreshInput(enqueue, input.signal, 'steer') + : enqueue(host.permissions?.appliedMode ?? host.permissions?.acquiredMode ?? 'ask') + if (admission.kind !== 'sent') { + host.dispatchEchoes.disarm(input.clientMessageId) + throw new StructuredAgentSessionInputNotAdmitted( + undefined, + admission.kind === 'acquire' ? admission.reason : 'permission' + ) + } + const answer = await admission.response + return { turnId: readCodexTurnId(answer) ?? expectedTurnId, via: 'steer' } + } catch (error) { + if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + return null + } + throw error + } +} 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 19f83474642..56a2afdfbbd 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 @@ -58,6 +58,12 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi ): boolean | undefined => this.liveOwnerOrNull(sessionId)?.permissionModeRequiresAcquisition?.(sessionId, mode) + settlePermissionTransition: NonNullable< + StructuredAgentSessionAdapter['settlePermissionTransition'] + > = (input) => + this.liveOwnerOrNull(input.sessionId)?.settlePermissionTransition?.(input) ?? + Promise.resolve(true) + /** Session support may narrow the owner’s declared rewind, never widen it. */ rewindSupport: NonNullable = ( 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 db8db75035c..2db845d875b 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 @@ -190,6 +190,13 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string, mode: AgentChatPermissionChoice ): boolean | undefined + /** Settle native work without withdrawing previously admitted input; false proves no permission transition. */ + settlePermissionTransition?(input: { + sessionId: string + fence: number + mode: AgentChatPermissionChoice + signal?: AbortSignal + }): Promise dispatch(input: { sessionId: string clientMessageId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts index c6ac2c94d3d..d362e66ebdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts @@ -1,3 +1,4 @@ +import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session/lifecycle/agent-session-mutation-envelope' import { agentJournalSubmissionKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key' import * as autoSandbox from '../../codex/codex-structured-auto-sandbox' @@ -545,3 +546,102 @@ it('keeps earlier B before historically forced proven-unrun A through close and await adapter.closeAll() } }) + +it('settles a changed running policy before compiling Auto, while unchanged input still steers', async () => { + const { codex, turns, permissionSend, began, pick, starts } = codexInputFinalizationFixture() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const running = await permissionSend('running') + await began(running) + const same = await permissionSend('same policy') + await vi.waitFor(() => + expect(codex.connections[0]?.calls.some((r) => r.method === 'turn/steer')).toBe(true) + ) + turns.echo(same) + await pick('auto') + const changed = await permissionSend('new policy') + await vi.waitFor(() => expect(starts()).toHaveLength(2)) + expect(codex.connections[0]?.calls.filter((r) => r.method === 'turn/steer')).toHaveLength(1) + expect(codex.connections[0]?.calls.some((r) => r.method === 'turn/interrupt')).toBe(true) + expect(codex.connections).toHaveLength(2) + expect(starts()[1]?.params).toMatchObject({ + clientUserMessageId: changed, + sandboxPolicy: { type: 'workspaceWrite' } + }) + expect(starts().filter((r) => r.params?.clientUserMessageId === changed)).toHaveLength(1) + await began(changed) +}) + +it('retains the process but starts a new turn for an explicit running permission change', async () => { + const { host, codex, permissionSend, began, pick, starts, row } = codexInputFinalizationFixture() + await began(await permissionSend('running full access')) + const child = host.collaboratorsForTests().sessions.get(SESSION)!.child + await pick('ask') + const changed = await permissionSend('ask on the next turn') + await vi.waitFor(() => expect(starts()).toHaveLength(2)) + expect(codex.connections).toHaveLength(1) + expect(host.collaboratorsForTests().sessions.get(SESSION)!.child).toBe(child) + expect(codex.connections[0]?.calls.filter((r) => r.method === 'turn/steer')).toHaveLength(0) + expect(starts()[1]?.params).toMatchObject({ + clientUserMessageId: changed, + approvalPolicy: 'on-request', + sandboxPolicy: { type: 'readOnly' } + }) + await began(changed) + expect((await row(changed))?.dispatchState).toBe('accepted') +}) + +it.each(['refused', 'grace'] as const)( + 'keeps new input unwritten when interruption is %s', + async (failure) => { + const { adapter, codex, permissionSend, began, pick, starts, row } = + codexInputFinalizationFixture() + await began(await permissionSend('running')) + if (failure === 'refused') { + codex.routes['turn/interrupt'] = () => { + throw new CodexAppServerRequestError('turn/interrupt', -32600, 'refused') + } + } else { + codex.routes['turn/interrupt'] = () => ({}) + vi.spyOn(adapter, 'awaitStoppedRequestEnd').mockResolvedValue() + } + await pick('ask') + const id = await permissionSend('must not inherit old grant') + await vi.waitFor(async () => expect((await row(id))?.dispatchState).toBe('rejected')) + expect((await row(id))?.rejection).toMatchObject({ + kind: 'restartFailed', + neverReachedAgent: true + }) + expect(starts()).toHaveLength(1) + expect(codex.connections[0]?.calls.some((r) => r.method === 'turn/steer')).toBe(false) + } +) + +it('settles the old policy when a steer refused after the saved choice changed falls back to start', async () => { + const { codex, permissionSend, began, pick, starts, row } = codexInputFinalizationFixture() + await began(await permissionSend('running full access')) + const refusal = Promise.withResolvers() + codex.routes['turn/steer'] = () => refusal.promise + const changed = await permissionSend('exact pending input') + await vi.waitFor(() => + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/steer')).toBe(true) + ) + await pick('ask') + refusal.reject(new CodexAppServerRequestError('turn/steer', -32600, 'steer refused')) + await vi.waitFor(() => expect(starts()).toHaveLength(2)) + const calls = codex.connections[0]!.calls + const interrupt = calls.findIndex((call) => call.method === 'turn/interrupt') + const fresh = calls.findIndex( + (call) => call.method === 'turn/start' && call.params?.clientUserMessageId === changed + ) + expect(interrupt).toBeGreaterThan(-1) + expect(fresh).toBeGreaterThan(interrupt) + expect(starts()[1]?.params).toMatchObject({ + clientUserMessageId: changed, + approvalPolicy: 'on-request', + sandboxPolicy: { type: 'readOnly' } + }) + expect(codex.connections).toHaveLength(1) + await began(changed) + expect((await row(changed))?.dispatchState).toBe('accepted') + expect(starts().filter((call) => call.params?.clientUserMessageId === changed)).toHaveLength(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts index 480f94bcd16..93f2bb742d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts @@ -68,7 +68,7 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz ) const id = await permissionSend('preserve me', { personality: 'friendly' }) await preparing.promise - await pick('auto') + await pick(failure === 'lost-steer' ? 'full-access' : 'auto') if (failure === 'refusal') { codex.routes['thread/start'] = codex.routes['thread/resume'] = () => { throw new AgentSessionAcquisitionRefusal('missing Codex', 'cliMissing') @@ -119,9 +119,6 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) await vi.waitFor(() => expect(continuingStructuredAgentSessionInput(journal, id)).toBe(true)) if (failure === 'crash') { - await vi.waitFor(() => - expect(continuingStructuredAgentSessionInput(journal, id)).toBe(true) - ) expect(await row(id)).toMatchObject({ dispatchState: 'pending' }) await assertInputRecoveredAfterSnapshot(id) } @@ -570,18 +567,21 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz signals.push(signal) return dispatch(input) }) - const retire = deps.retireOutgrownChild - const retired = vi.spyOn(deps, 'retireOutgrownChild').mockImplementation(async (...args) => { - if ( - boundary === 'retire' && - args[1] && - continuingStructuredAgentSessionInput(journal, args[1]) - ) { - entered.resolve() - await held.promise - } - return retire(...args) - }) + const retire = deps.stopPermissionTransitionChild! + const retired = vi + .spyOn(deps, 'stopPermissionTransitionChild') + .mockImplementation(async (...args) => { + if ( + boundary === 'retire' && + journal + .submissions() + .some((s) => continuingStructuredAgentSessionInput(journal, s.clientMessageId)) + ) { + entered.resolve() + await held.promise + } + return retire(...args) + }) const open = codex.routes['thread/start']! const opening = vi.fn(async (params: Parameters[0]) => { if (boundary === 'acquire' || boundary === 'starting-exit') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts index cff15756282..1ea3dace968 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts @@ -103,15 +103,6 @@ beforeEach(async () => { notify = (method, params) => codex.connections.at(-1)?.handlers.onNotification?.(method, params) turns = codexTurnLifecycleFake(THREAD, () => notify) Object.assign(codex.routes, turns.routes) - codex.routes['turn/interrupt'] = () => { - const turnId = turns.turnId - setTimeout( - () => - notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), - 4 - ) - return {} - } launch = {} adapter = adapterFor(codex, launch, [], { resolveLaunch: async ({ permissionMode, identity }) => ({ @@ -402,7 +393,6 @@ it.each(['config', 'order', 'initial'] as const)( it.each([ ['ask', 1], ['auto', 1], - ['auto-before', 1], ['empty', 1], ['auto', 10], ['auto', 100] @@ -455,9 +445,6 @@ it.each([ population > 1 ? measureCodexInputWork(host, events, 'history', population) : undefined let waiting: ReturnType | undefined try { - if (mode === 'auto-before') { - await pick('auto') - } const id = await permissionSend('follow up') if (measured) { waiting = host.waitForSendSettlement(SESSION, id) @@ -470,9 +457,7 @@ it.each([ ) ).toBe(true) ) - if (mode !== 'auto-before') { - await pick(mode === 'empty' ? 'ask' : mode) - } + await pick(mode === 'empty' ? 'ask' : mode) if (mode === 'empty') { await journal.resolveDispatch({ clientMessageId: id, @@ -540,40 +525,6 @@ it.each([ } ) -it('preserves successful steering and only current retained work can inherit unknown Auto', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const running = await permissionSend('running') - await began(running) - await pick('auto') - const steer = await permissionSend('steer') - await vi.waitFor(() => - expect(codex.connections[0]?.calls.some((r) => r.method === 'turn/steer')).toBe(true) - ) - turns.echo(steer) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.runtimePermission?.mode).toBe('full-access') - ) - turns.end('completed') - await host.flushStreamedEvents(SESSION) - retained = true - const busy = await permissionSend('work still held elsewhere') - await vi.waitFor(() => expect(starts()).toHaveLength(2)) - expect(starts()[1]?.params).not.toHaveProperty('sandboxPolicy') - expect(codex.connections).toHaveLength(1) - await began(busy) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.runtimePermission?.mode).toBe('full-access') - ) - expect((await host.readOptions(SESSION)).current.reportedPermissionMode).toBe('full-access') - turns.end('completed') - await host.flushStreamedEvents(SESSION) - retained = false - const idle = await permissionSend('now idle') - await vi.waitFor(() => expect(starts()).toHaveLength(3)) - expect(codex.connections).toHaveLength(2) - expect(starts()[2]?.params).toMatchObject({ clientUserMessageId: idle, sandboxPolicy: sandbox }) -}) - it.each(['stop', 'local-abort'] as const)( 'keeps an actual sent envelope when %s precedes its Promise delivery and the reply is lost', async (cancellation) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts index df4cccbc547..b62f7ce2652 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts @@ -36,6 +36,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { next?: AgentJournalSubmission ) => Promise immediatePermission?: (sessionId: string) => AgentChatPermissionChoice | null + stopPermissionTransitionChild?: (sessionId: string) => Promise retainedWork?: (sessionId: string, continuingId: string) => boolean readiness: ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts index c0d469c0da3..bffb35863ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts @@ -11,6 +11,7 @@ type HandoverJournal = Pick type HandoverInput = { owner: () => void unadmitted: boolean + compilerPermission?: 'auto' signal?: AbortSignal pending?: () => boolean finalization: { close: (cause: UnsentSendHold['cause']) => void; completion: Promise } @@ -67,12 +68,14 @@ export function markStructuredAgentSessionInputUnadmitted( clientMessageId: string, owner: () => void, unadmitted: boolean, - signal?: AbortSignal + signal?: AbortSignal, + compilerPermission?: 'auto' ): void { const input = handingOver.get(journal)?.get(clientMessageId)?.input if (input?.owner === owner) { Object.assign(input, { unadmitted, + compilerPermission, signal, pending: () => journal @@ -82,6 +85,15 @@ export function markStructuredAgentSessionInputUnadmitted( } } +export function continuingStructuredAgentSessionCompilerPermission( + journal: HandoverJournal, + clientMessageId: string +): 'auto' | undefined { + return continuingStructuredAgentSessionInput(journal, clientMessageId) + ? handingOver.get(journal)?.get(clientMessageId)?.input?.compilerPermission + : undefined +} + /** Runs one handover with its send counted as held, from before its pending row until the adapter * answered; `released` then republishes what is current, which no row may follow. */ export async function whileStructuredAgentSessionHandingOver( 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 4283e7c6d1c..d331d83e0ce 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 @@ -160,11 +160,16 @@ export async function beginSubmissionHandover( clientMessageId, released, true, - attempt?.signal + attempt?.signal, + error.reason === 'auto' ? 'auto' : undefined + ) + const next = await ctx.continueInput( + attempt?.signal, + async (fence) => { + await prepareFresh(fence) + }, + error.reason ) - const next = await ctx.continueInput(attempt?.signal, async (fence) => { - await prepareFresh(fence) - }) if (next.kind === 'failed') { unwritten = true ctx.fence = next.fence @@ -239,7 +244,10 @@ async function dispatchSafely( ...(executionOptions ? { executionOptions } : {}), ...(signal ? { signal } : {}), ...(admitFreshInput - ? { admitFreshInput: (enqueue) => admitFreshInput(enqueue, signal, prepare) } + ? { + admitFreshInput: (enqueue, _signal, kind) => + admitFreshInput(enqueue, signal, prepare, kind) + } : {}), isCurrent, fence: ctx.fence, 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 0805b0cf531..fcc04b9edac 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 @@ -75,6 +75,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { /** Stops a child the chat outgrew, so the start after it reads a new login, account or permission * mode. */ stopOutgrownAgent: (sessionId: string) => Promise + stopPermissionTransitionChild: (sessionId: string) => Promise clientDelivery: Pick< StructuredAgentSessionClientDelivery, | 'publishRestored' @@ -106,6 +107,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, startProviderChild: input.startProviderChild, + stopPermissionTransitionChild: input.stopPermissionTransitionChild, retireOutgrownChild: async (sessionId, continuingId, next) => { // Defer retirement while personal Stop holds handover; its next step decides. const session = input.clientDelivery.readStopping(sessionId) @@ -129,7 +131,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { ) }, permissionRequiresAcquisition: (mode) => - continuingId + structuredAgentSessionInputCompilerPermission(session?.journal, continuingId) ? true : (deps.adapter.permissionModeRequiresAcquisition?.(sessionId, mode) ?? mode !== session?.child?.permissionMode), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 1fafa0fd876..10a86a5748e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -101,6 +101,7 @@ export class StructuredAgentSessionHost { readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Retire at rest; the next start refreshes login and permission mode. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), + stopPermissionTransitionChild: (id) => this.lifetime.stopAgent(id, { cause: 'evict' }), clientDelivery: this.clientDelivery }) this.startup = createStartupRecovery(deps, this.runtimeState.reconcileLeases, this.runtimeState) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts index 91011948531..1c3d5427e2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts @@ -4,7 +4,7 @@ import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permi export type StructuredAgentSessionInputAdmission = | { kind: 'prepare' } - | { kind: 'acquire' } + | { kind: 'acquire'; reason?: 'permission' | 'auto' } | { kind: 'sent'; response: Promise } /** The synchronous writer runs under host ordering; its response is awaited outside it. */ @@ -14,18 +14,21 @@ export type StructuredAgentSessionAdmitFreshInput = ( inheritRunning: boolean, body?: AgentJournalMessageItem ) => StructuredAgentSessionInputAdmission, - signal?: AbortSignal + signal?: AbortSignal, + kind?: 'fresh' | 'steer' ) => Promise export type StructuredAgentSessionAdmitHostInput = ( enqueue: Parameters[0], signal: AbortSignal | undefined, - prepare: (fence: number) => Promise + prepare: (fence: number) => Promise, + kind?: 'fresh' | 'steer' ) => ReturnType export type StructuredAgentSessionContinueInput = ( signal: AbortSignal | undefined, - rebind: (fence: number) => Promise + rebind: (fence: number) => Promise, + reason?: 'permission' | 'auto' ) => Promise< | { kind: 'ready'; isCurrent: () => boolean } | { kind: 'failed'; fence: number; outcome: AgentSessionDispatchOutcome } @@ -33,7 +36,36 @@ export type StructuredAgentSessionContinueInput = ( /** Host-local control, never a provider failure or a replay of a possibly written request. */ export class StructuredAgentSessionInputNotAdmitted extends Error { - constructor(readonly outcome?: Extract) { + constructor( + readonly outcome?: Extract, + readonly reason: 'permission' | 'auto' = 'auto' + ) { super('input was not admitted') } } + +/** Commit provider ownership in the same ordered step that checks the saved choice. */ +export async function admitStructuredAgentSessionInput( + admit: StructuredAgentSessionAdmitFreshInput | undefined, + commit: () => void +): Promise { + if (!admit) { + commit() + return + } + const result = await admit( + () => { + commit() + return { kind: 'sent', response: Promise.resolve() } + }, + undefined, + 'steer' + ) + if (result.kind !== 'sent') { + throw new StructuredAgentSessionInputNotAdmitted( + undefined, + result.kind === 'acquire' ? result.reason : 'permission' + ) + } + await result.response +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts index 36b7fe5ca2f..7c1a3782dcc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts @@ -22,11 +22,12 @@ import { } from './structured-agent-session-failure-text' import { refusedStructuredAgentSessionStart, - structuredAgentSessionStartFailureRow, + failedStructuredAgentSessionInputContinuation, type startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { sameProviderChild } from './structured-agent-session-provider-child' +import { previousExitUnverifiableRefusal } from './structured-agent-session-child-close' type StartFailure = NonNullable> @@ -43,9 +44,7 @@ export function beginStructuredAgentSessionInputHandover( let recorder = deps.recordRanOptions?.(sessionId, binding.fence) const ownsInput = () => deps.sessions.get(sessionId) === session && - session.journal - .submissions() - .some((s) => s.clientMessageId === next.clientMessageId && s.dispatchState === 'pending') + session.journal.submission(next.clientMessageId)?.dispatchState === 'pending' const isBound = () => deps.sessions.get(sessionId) === session && session.child === binding const isCurrent = () => isBound() && !binding.close const validate = (signal?: AbortSignal) => { @@ -83,18 +82,22 @@ export function beginStructuredAgentSessionInputHandover( failureTextContext: deps.failureTextContext(sessionId), record: () => deps.record(sessionId), - admitFreshInput: async (enqueue, signal, prepare) => { + admitFreshInput: async (enqueue, signal, prepare, kind = 'fresh') => { let admission: StructuredAgentSessionInputAdmission | undefined const invoked = deps.serialize(sessionId, async () => { validate(signal) - const body = await prepare(binding.fence) + const body = kind === 'fresh' ? await prepare(binding.fence) : undefined validate(signal) const mode = deps.immediatePermission?.(sessionId) - if (!mode) { - throw new Error('input handover has no permission choice') + if ( + mode && + (deps.adapter.permissionModeRequiresAcquisition?.(sessionId, mode) ?? + mode !== binding.permissionMode) + ) { + return { kind: 'acquire' as const, reason: 'permission' as const } } admission = enqueue( - mode, + mode ?? 'own', deps.retainedWork?.(sessionId, next.clientMessageId) === true, body ) @@ -111,36 +114,58 @@ export function beginStructuredAgentSessionInputHandover( throw error } }, - continueInput: async (signal, rebind) => { - const failed = async ( + continueInput: async (signal, rebind, reason = 'auto') => { + const failed = ( cause: StartFailure['cause'], fence: number, startKey = next.clientMessageId - ) => { - const words = structuredAgentSessionStartFailure( + ) => + failedStructuredAgentSessionInputContinuation( + session.journal, + deps.failureTextContext(sessionId), cause, - deps.failureTextContext(sessionId) - ) - if (!('failure' in cause && cause.failure.kind === 'cancelled')) { - await session.journal.appendLifecycleBatch({ - settlementId: `start-failure:${startKey}`, - fence, - recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, words)] - }) - } - return { - kind: 'failed' as const, fence, - outcome: { state: 'rejected' as const, ...words } - } - } + startKey + ) try { + const mode = await waitForPromiseWithSignal( + deps.serialize(sessionId, async () => { + validate(signal) + return deps.immediatePermission?.(sessionId) + }), + signal + ) + const transitionNeeded = + mode && + (reason === 'auto' || + mode !== binding.permissionMode || + deps.adapter.permissionModeRequiresAcquisition?.(sessionId, mode) !== undefined) + if ( + transitionNeeded && + deps.adapter.settlePermissionTransition && + !(await waitForPromiseWithSignal( + deps.adapter.settlePermissionTransition({ + sessionId, + fence: binding.fence, + mode, + signal + }), + signal + )) + ) { + return deps.serialize(sessionId, () => + failed( + { + failure: agentSessionFailureFact('restartFailed', { neverReachedAgent: true }) + }, + binding.fence + ) + ) + } const claim = await waitForPromiseWithSignal( deps.serialize(sessionId, async () => { validate(signal) if ( - !ownsInput() || !session.journal .submissions() .some( @@ -150,15 +175,37 @@ export function beginStructuredAgentSessionInputHandover( ) { throw new Error('input continuation no longer owns its submission') } - await deps.retireOutgrownChild(sessionId, next.clientMessageId) + const latest = deps.immediatePermission?.(sessionId) + const acquire = + (reason === 'auto' && latest === 'auto') || + (!!latest && + (deps.adapter.permissionModeRequiresAcquisition?.(sessionId, latest) ?? + latest !== binding.permissionMode)) + if (!acquire) { + return null + } + await (deps.stopPermissionTransitionChild + ? deps.stopPermissionTransitionChild(sessionId) + : deps.retireOutgrownChild(sessionId, next.clientMessageId)) signal?.throwIfAborted() - if (!ownsInput() || (session.child && session.child !== binding)) { + const ended = session.lastEndedChild + if ( + !ownsInput() || + session.child || + !ended || + !sameProviderChild(ended, binding) || + !ended.rootGone || + ended.cause !== 'evict' + ) { throw new Error('input continuation lost its provider binding') } return deps.startProviderChild(sessionId, next.clientMessageId) }), signal ) + if (!claim) { + return { kind: 'ready', isCurrent } + } const started = await waitForPromiseWithSignal(claim.outcome, signal) if (!started.ok) { return deps.serialize(sessionId, () => @@ -219,11 +266,9 @@ export function beginStructuredAgentSessionInputHandover( } return deps.serialize(sessionId, () => failed( - { - failure: signal?.aborted - ? agentSessionFailureFact('cancelled') - : providerStartupFailureFact(error) - }, + binding.close && isBound() + ? { refusal: previousExitUnverifiableRefusal() } + : { failure: providerStartupFailureFact(error) }, deps.conversationFence(sessionId) ) ) 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 fd93fdf00e4..61e3f87b63c 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 @@ -1,6 +1,6 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' -import { continuingStructuredAgentSessionInput } from './structured-agent-session-handing-over' +import { continuingStructuredAgentSessionCompilerPermission } from './structured-agent-session-handing-over' import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-record' import { agentChatPermissionModes, @@ -34,11 +34,11 @@ export function structuredAgentSessionImmediatePermission( /** Only the exact live, positively unwritten input pins its acquisition compiler to Auto. */ export function structuredAgentSessionInputCompilerPermission( - journal: Parameters[0] | undefined, + journal: Parameters[0] | undefined, startedFor?: string ): 'auto' | undefined { - return journal && startedFor && continuingStructuredAgentSessionInput(journal, startedFor) - ? 'auto' + return journal && startedFor + ? continuingStructuredAgentSessionCompilerPermission(journal, startedFor) : undefined } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts index 29da9f89594..90ae8c8c023 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -1,7 +1,9 @@ +import { admitStructuredAgentSessionInput } from './structured-agent-session-input-admission' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session/lifecycle/agent-session-mutation-envelope' import { createRestTestRig, + acceptedDispatch, foundRestTestChat, IDLE_MS, REST_TEST_CALLER as CALLER, @@ -37,6 +39,10 @@ describe('a chat permission mode', () => { beforeEach(async () => { rig = await createRestTestRig() + rig.adapter.dispatch.mockImplementation(async (input) => { + await admitStructuredAgentSessionInput(input.admitFreshInput, () => {}) + return acceptedDispatch() + }) }) afterEach(async () => { @@ -439,16 +445,16 @@ describe('a chat permission mode', () => { expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) }) - it('keeps the running agent for a send while its reply runs', async () => { + it('reacquires a launch-bound agent before admitting input under a changed running policy', async () => { await foundRestTestChat(rig) rig.adapter.closeSession.mockClear() const holds = rig.adapter.holdsDispatch.mockReturnValue(true) await pick('full-access') expect((await rig.host.send(CALLER, restTestSend('mid-reply', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.closeSession).not.toHaveBeenCalled() - expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.adapter.closeSession).toHaveBeenCalled() + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) holds.mockReturnValue(false) }) }) 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 52f78d0a1cf..d9b9007a2cb 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 @@ -161,3 +161,22 @@ export async function recordStructuredAgentSessionDeliveryFailure( clientMessageId ) } + +export async function failedStructuredAgentSessionInputContinuation( + journal: StructuredAgentSessionHostSession['journal'], + context: AgentSessionFailureWordsContext, + cause: StructuredAgentSessionStartFailureCause, + fence: number, + startKey: string +) { + const words = structuredAgentSessionStartFailure(cause, context) + if (!('failure' in cause && cause.failure.kind === 'cancelled')) { + await journal.appendLifecycleBatch({ + settlementId: `start-failure:${startKey}`, + fence, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow(startKey, words)] + }) + } + return { kind: 'failed' as const, fence, outcome: { state: 'rejected' as const, ...words } } +} diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx index 38827ecd858..8882793e8aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -211,59 +211,52 @@ describe('NativeChatComposerPermissionPicker', () => { ['codex', 'full-access', 'Full access'], ['claude', 'plan', 'Plan mode'], ['codex', 'read-only', 'Read only'] - ])( - 'checks only an offered reported mode for %s/%s and clears on report loss', - (agent, mode, label) => { - const options = surface() - const state = createStructuredAgentSessionOptionState(agent, null) - const snapshot = (reportedPermissionMode?: string | null) => { - const row = structuredAgentSessionPermissionDescriptor({ - ...state, - permissionMode: 'own', - permissionOwnSupported: true, - reportedPermissionMode - }) - return row ? [row] : [] - } - const { rerender } = render( - - ) - expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true') - for (const report of [mode, null, mode, undefined]) { - rerender( - - ) - expect( - screen.getByRole('button', { name: `Permissions ${report ? label : 'Permissions'}` }) - ).toBeTruthy() - expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe( - report ? null : 'true' - ) - expect( - screen - .getAllByRole('radio') - .filter((row) => row.getAttribute('aria-checked') === 'true') - .map((row) => row.getAttribute('data-value')) - ).toEqual(report && ['accept-edits', 'full-access'].includes(report) ? [report] : []) - expect( - screen.getAllByRole('radio').some((row) => row.getAttribute('data-value') === 'own') - ).toBe(false) - } + ])('keeps Own selected and editable for %s despite report %s', (agent, mode) => { + const options = surface() + const state = createStructuredAgentSessionOptionState(agent, null) + const snapshot = (reportedPermissionMode?: string | null) => { + const row = structuredAgentSessionPermissionDescriptor({ + ...state, + permissionMode: 'own', + permissionOwnSupported: true, + reportedPermissionMode + }) + return row ? [row] : [] + } + const { rerender } = render( + + ) + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe(null) + for (const report of [mode, null, mode, undefined]) { rerender( ) - fireEvent.click(option('full-access')) - expect(options.setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access') + expect(screen.getByRole('button', { name: "Permissions Agents' own settings" })).toBeTruthy() + expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe(null) + expect( + screen + .getAllByRole('radio') + .filter((row) => row.getAttribute('aria-checked') === 'true') + .map((row) => row.getAttribute('data-value')) + ).toEqual([]) + expect( + screen.getAllByRole('radio').some((row) => row.getAttribute('data-value') === 'own') + ).toBe(false) } - ) + rerender( + + ) + fireEvent.click(option('full-access')) + expect(options.setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access') + }) it('renders nothing without a permission descriptor', () => { const { container } = render( diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx index 21b461dc7ab..fc237a9b84b 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.tsx @@ -99,7 +99,9 @@ function NativeChatPermissionModePickerInner({ const title = nativeChatPermissionPickerTitle() const label = isAgentChatPermissionMode(current) ? nativeChatPermissionModeLabel(current) - : (currentLabel ?? title) + : current === 'own' + ? translate('settings.agents.permissionOwnAll', "Agents' own settings") + : (currentLabel ?? title) // `shrink` overrides the button's shrink-0 so a narrow composer truncates the mode name. return ( 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 126cb8b00fc..17907c8eeb0 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 @@ -138,7 +138,7 @@ describe('the permission mode another device picks', () => { unmount() }) - it('waits for the new child’s report after an own-settings chat restarts', async () => { + it('keeps Own distinct from the running report across a restart', async () => { optionsAnswering('own') const read = mocks.call.getMockImplementation()! mocks.call.mockImplementation(async (...args) => { @@ -151,13 +151,15 @@ describe('the permission mode another device picks', () => { committed.push(`${entry?.description}/${entry?.settable}`) }) const row = () => result.current.optionSnapshot.find((entry) => entry.id === 'permissionMode') - await waitFor(() => expect(row()).toMatchObject({ description: 'Full access', settable: true })) + await waitFor(() => + expect(row()).toMatchObject({ description: "Agent's own settings", settable: true }) + ) mocks.call.mockImplementation(() => new Promise(() => {})) committed.length = 0 rerender({ fence: 2, stamp: 'model-a|own' }) // Every frame of the new process, including the one before its read lease attaches. - expect([...new Set(committed)]).toEqual(['Permissions/false']) - expect(row()).toMatchObject({ description: 'Permissions', settable: false }) + expect([...new Set(committed)]).toEqual(["Agent's own settings/true"]) + expect(row()).toMatchObject({ description: "Agent's own settings", settable: true }) unmount() }) @@ -204,7 +206,7 @@ describe('a permission pick on this device', () => { ) vi.mocked(enqueueSessionOptionSettingsWrite).mockClear() const { result, unmount } = renderReopened(1) - await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('full-access')) + await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('own')) await act(async () => { expect(await result.current.setStructuredOption('permissionMode', 'full-access')).toBe(true) }) diff --git a/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx b/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx index 9fe9f4549f1..38199364b3b 100644 --- a/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx +++ b/src/renderer/src/lib/structured-agent-session-selection-lifetime.test.tsx @@ -439,7 +439,7 @@ it.each([false, true])( } expect(descriptor('model')?.kind).toMatchObject({ currentValue: 'gpt-5.5' }) expect(descriptor('permissionMode')).toMatchObject({ - description: 'Full access', + description: "Agent's own settings", settable: true }) let picked: Promise | undefined @@ -460,8 +460,8 @@ it.each([false, true])( model: 'saved' }) expect(descriptor('permissionMode')).toMatchObject({ - description: 'Permissions', - settable: false + description: "Agent's own settings", + settable: true }) await act(async () => reads[1].resolve({ diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx index 4cb85d7555d..9fd94041183 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx @@ -146,7 +146,7 @@ it.each([10, 100])( const picker = view.result.current.picker return ( picker?.settable === true && - picker.valueSource === 'reported' && + picker.valueSource === 'applied' && picker.description === 'Ask for approval' && picker.kind.type === 'select' && picker.kind.currentValue === 'ask' && diff --git a/src/shared/agent-session/options/structured-agent-session-permission-option.test.ts b/src/shared/agent-session/options/structured-agent-session-permission-option.test.ts index dcdf5129199..83d78154ef8 100644 --- a/src/shared/agent-session/options/structured-agent-session-permission-option.test.ts +++ b/src/shared/agent-session/options/structured-agent-session-permission-option.test.ts @@ -39,7 +39,7 @@ function live(agent: string, current: AgentSessionOptionsResult['current'], auto } describe('the permission mode picker', () => { - it('shows the selected mode for the next turn, except for own, which follows the report', () => { + it('keeps the saved choice, including Own, independent of runtime reports', () => { const pr1 = live('codex', { permissionMode: 'ask' }) expect(permission({ ...pr1, permissionOwnSupported: null })?.settable).toBe(false) expect(permission({ ...pr1, permissionOwnSupported: false })).toMatchObject({ @@ -50,9 +50,9 @@ describe('the permission mode picker', () => { for (const reportedPermissionMode of [undefined, null, 'full-access', 'read-only']) { const descriptor = permission({ ...capable, reportedPermissionMode }) expect(descriptor).toMatchObject({ - settable: typeof reportedPermissionMode === 'string', + settable: true, kind: { - currentValue: reportedPermissionMode ?? undefined, + currentValue: 'own', choices: expect.not.arrayContaining([expect.objectContaining({ value: 'own' })]) } }) @@ -68,7 +68,21 @@ describe('the permission mode picker', () => { ).toMatchObject({ settable: true, kind: { currentValue: 'full-access' } }) }) - it('admits supported Own reset through the same descriptor/readiness predicate', () => { + it.each(['claude', 'codex', 'grok', 'opencode', 'opencode2', 'omp', 'antigravity'])( + 'keeps a saved Ask choice editable for %s despite stale or missing reports', + (agent) => { + const state = live(agent, { permissionMode: 'ask' }) + for (const reportedPermissionMode of [undefined, null, 'full-access', 'retired-mode']) { + expect(permission({ ...state, reportedPermissionMode })).toMatchObject({ + settable: true, + valueSource: 'applied', + kind: { currentValue: 'ask' } + }) + } + } + ) + + it('admits supported Own delegation through the same descriptor/readiness predicate', () => { const state = { ...live('codex', { permissionMode: 'ask' }), permissionOwnSupported: true } expect(canSetStructuredAgentSessionOption(state, 'permissionMode', 'own')).toBe(true) for (const permissionOwnSupported of [false, null]) { diff --git a/src/shared/agent-session/options/structured-agent-session-permission-option.ts b/src/shared/agent-session/options/structured-agent-session-permission-option.ts index 3b46512ba69..5976733d7d2 100644 --- a/src/shared/agent-session/options/structured-agent-session-permission-option.ts +++ b/src/shared/agent-session/options/structured-agent-session-permission-option.ts @@ -1,4 +1,4 @@ -// Codex picks describe the next turn; other modes follow the running agent’s report. +// Saved choices describe requested policy; runtime observations never replace that intent. import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, @@ -62,21 +62,17 @@ export function structuredAgentSessionPermissionDescriptor(state: { if (!support || (!own && !support.modes.some((mode) => mode === state.permissionMode))) { return null } - const selectedForNextTurn = state.record.agent === 'codex' && !own - const current = - !selectedForNextTurn && state.reportedPermissionMode !== undefined - ? (state.reportedPermissionMode ?? undefined) - : own - ? undefined - : (state.permissionMode ?? undefined) + const current = state.permissionMode ?? undefined const reported = support.modes.find((mode) => mode === current) - const label = reported - ? MODE_COPY[reported].label - : current === 'plan' - ? 'Plan mode' - : current === 'read-only' - ? 'Read only' - : (current ?? 'Permissions') + const label = own + ? "Agent's own settings" + : reported + ? MODE_COPY[reported].label + : current === 'plan' + ? 'Plan mode' + : current === 'read-only' + ? 'Read only' + : (current ?? 'Permissions') return { description: label, id: AGENT_CHAT_PERMISSION_MODE_OPTION_ID, @@ -91,14 +87,9 @@ export function structuredAgentSessionPermissionDescriptor(state: { description: agentDescriptions(state.record.agent)[value] ?? MODE_COPY[value].description })) }, - valueSource: - current === undefined - ? 'unknown' - : current === state.reportedPermissionMode - ? 'reported' - : 'applied', + valueSource: 'applied', transport: 'agent-session', - settable: state.permissionOwnSupported !== null && current !== undefined + settable: state.permissionOwnSupported !== null } } diff --git a/tests/e2e/structured-permission-report-publication.unit.test.ts b/tests/e2e/structured-permission-report-publication.unit.test.ts index 44a71505f16..635ea11b8a1 100644 --- a/tests/e2e/structured-permission-report-publication.unit.test.ts +++ b/tests/e2e/structured-permission-report-publication.unit.test.ts @@ -14,6 +14,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: transport.call, subscribeStructuredAgentSessionStatus: transport.subscribe })) +import { useStructuredAgentSessionOptionStamp } from '../../src/renderer/src/components/native-chat/structured-agent-session-status-hooks' import { useStructuredAgentSessionOptions } from '../../src/renderer/src/components/native-chat/use-structured-agent-session-options' import { loadMobileStructuredOptions } from './__mocks__/mobile-structured-options' const useMobileStructuredAgentOptions = await loadMobileStructuredOptions() @@ -134,7 +135,7 @@ function permissionConsumersFor( providerStarting: false, turnId: null, unloadedTurnRevisions: undefined, - hostOptionStamp: 'model|own', + hostOptionStamp: useStructuredAgentSessionOptionStamp(sessionId, target), mutate: write }), () => @@ -142,7 +143,7 @@ function permissionConsumersFor( ...common, client, enabled: true, - optionStamp: 'model|own', + optionStamp: useStructuredAgentSessionOptionStamp(sessionId, target), turnId: null, hostSupport: { permissionModes: true, @@ -252,7 +253,9 @@ it.each([1, 10, 100])( view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode') ?.description ) - await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Accept edits'))) + await waitFor(() => + expect(descriptions()).toEqual(Array(count * 2).fill("Agent's own settings")) + ) sink.publish.mockClear() onStatusChanged.mockClear() const unchanged = host.readStatusSummary(sessionId)! @@ -281,7 +284,9 @@ it.each([1, 10, 100])( claudeFrame(connection, frame) await Promise.all(settlements) }) - await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Ask for approval'))) + await waitFor(() => + expect(descriptions()).toEqual(Array(count * 2).fill("Agent's own settings")) + ) expect(read.mock.calls.filter(([method]) => method === 'agentSession.options')).toHaveLength( reads ) @@ -344,7 +349,7 @@ it.each([1, 10, 100])( }) views.forEach((view) => view.unmount()) views = mount() - await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Ask for approval'))) + await waitFor(() => expect(descriptions()).toEqual(Array(count * 2).fill('Full access'))) expect((await host.readOptions(sessionId)).current).toMatchObject({ permissionMode: 'full-access', reportedPermissionMode: 'ask' @@ -372,7 +377,7 @@ it.each([1, 10, 100])( await act(async () => { subscriptions.forEach((dispose) => dispose()) }) - // Report loss falls back to the saved mode, which the next turn applies. + // Losing runtime evidence does not change the saved selection. expect(descriptions()).toEqual(Array(count * 2).fill('Full access')) } finally { cleanup() @@ -395,7 +400,7 @@ it.each([ ), { agent: 'omp' as const, fullAccess: false, savedMode: 'own' as const } ])( - 'keeps $agent saved $savedMode controls tied to installed fullAccess=$fullAccess, not saved intent', + 'keeps $agent saved $savedMode selected despite installed fullAccess=$fullAccess', async ({ agent, fullAccess, savedMode }) => { const spec = acpLaunchSpecFor(agent) if (!spec) { @@ -409,7 +414,13 @@ it.each([ }) const mode = fullAccess ? 'full-access' : 'ask' const other = fullAccess ? 'ask' : 'full-access' - const description = fullAccess ? 'Full access' : 'Ask for approval' + const label = (choice: string) => + choice === 'own' + ? "Agent's own settings" + : choice === 'full-access' + ? 'Full access' + : 'Ask for approval' + const description = label(savedMode) try { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY]) expect( @@ -468,7 +479,7 @@ it.each([ views.forEach((view) => view.unmount()) views = mount() await waitFor(() => - expect(rows().map((row) => row?.description)).toEqual([description, description]) + expect(rows().map((row) => row?.description)).toEqual([label(other), label(other)]) ) expect(rows().every((row) => row?.settable)).toBe(true) expect((await host.readOptions(sessionId)).current).toMatchObject({ @@ -548,7 +559,9 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta (view) => view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode')?.description ) - await waitFor(() => expect(descriptions()).toEqual(['Approve for me', 'Approve for me'])) + await waitFor(() => + expect(descriptions()).toEqual(["Agent's own settings", "Agent's own settings"]) + ) const desktop = views[0]! await act(async () => { const applied = await desktop.result.current.setStructuredOption('permissionMode', 'ask') @@ -581,7 +594,7 @@ it('publishes accepted Codex fresh policy to desktop and phone without stale sta turn: { id: 'applied-ask' } }) expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ fence, mode: 'auto' }) - expect(descriptions()).toEqual(['Ask for approval', 'Approve for me']) + expect(descriptions()).toEqual(['Ask for approval', 'Ask for approval']) await act(async () => { answer.resolve({ turn: { id: 'applied-ask' } }) expect(await finished).toMatchObject({ value: { state: 'admitted' } })