Honor selected chat permissions before accepting input (#37543)

* Honor saved chat permissions at input admission

* Cover fallback and autonomous turns in permission transitions

* Preserve host dispatch refusals and verify saved permission publication
This commit is contained in:
Brennan Benson
2026-11-02 19:09:32 -08:00
committed by GitHub
parent ca865dd64f
commit ba5613d5e5
53 changed files with 1019 additions and 360 deletions
+3
View File
@@ -2650,6 +2650,9 @@
"title": "Spracheingabe",
"toggleMode": "Umschalten",
"updateFailed": "Aktualisierung fehlgeschlagen"
},
"agents": {
"permissionOwnAll": "Eigene Einstellungen der Agenten"
}
},
"sourceControl": {
+3
View File
@@ -145,6 +145,9 @@
"speechModelHeading": "SPEECH MODEL",
"speechModel": "Speech Model",
"loading": "Loading voice settings…"
},
"agents": {
"permissionOwnAll": "Agents' own settings"
}
},
"common": {
+3
View File
@@ -2721,6 +2721,9 @@
"title": "Voz",
"toggleMode": "Alternar",
"updateFailed": "No se pudo actualizar"
},
"agents": {
"permissionOwnAll": "Configuración propia de los agentes"
}
},
"sourceControl": {
+3
View File
@@ -2721,6 +2721,9 @@
"title": "Voix",
"toggleMode": "Bascule",
"updateFailed": "Impossible de mettre à jour"
},
"agents": {
"permissionOwnAll": "Paramètres propres aux agents"
}
},
"sourceControl": {
+3
View File
@@ -2646,6 +2646,9 @@
"title": "音声",
"toggleMode": "トグル",
"updateFailed": "更新できませんでした"
},
"agents": {
"permissionOwnAll": "Agent 独自の設定"
}
},
"sourceControl": {
+3
View File
@@ -2646,6 +2646,9 @@
"title": "음성",
"toggleMode": "토글",
"updateFailed": "업데이트하지 못했습니다."
},
"agents": {
"permissionOwnAll": "에이전트 자체 설정"
}
},
"sourceControl": {
+3
View File
@@ -2721,6 +2721,9 @@
"title": "Voz",
"toggleMode": "Alternar",
"updateFailed": "Não foi possível atualizar"
},
"agents": {
"permissionOwnAll": "Configurações próprias dos agentes"
}
},
"sourceControl": {
+3
View File
@@ -2792,6 +2792,9 @@
"title": "Голос",
"toggleMode": "Переключение",
"updateFailed": "Не удалось обновить"
},
"agents": {
"permissionOwnAll": "Собственные настройки агентов"
}
},
"sourceControl": {
+3
View File
@@ -2650,6 +2650,9 @@
"title": "Ses",
"toggleMode": "Aç/kapat",
"updateFailed": "Güncellenemedi"
},
"agents": {
"permissionOwnAll": "Ajanların kendi ayarları"
}
},
"sourceControl": {
+3
View File
@@ -2646,6 +2646,9 @@
"title": "語音",
"toggleMode": "切換",
"updateFailed": "無法更新"
},
"agents": {
"permissionOwnAll": "各代理程式自身的設定"
}
},
"sourceControl": {
+3
View File
@@ -2646,6 +2646,9 @@
"title": "语音",
"toggleMode": "切换",
"updateFailed": "无法更新"
},
"agents": {
"permissionOwnAll": "各智能体自己的设置"
}
},
"sourceControl": {
@@ -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'],
@@ -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')
}
})
@@ -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')
@@ -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(
+4 -2
View File
@@ -127,15 +127,17 @@ export class AcpStructuredLane {
}
/** Resolves when the turn leaves or further writes become impossible. */
whenTurnLeaves(turnId: string): Promise<void> {
whenTurnLeaves(turnId: string, signal?: AbortSignal): Promise<void> {
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()
})
}
+16 -15
View File
@@ -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<void> => {
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)()
}
+119
View File
@@ -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()
+54 -7
View File
@@ -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<AcpStructuredSession, 'connection' | 'lane' | 'prompts' | 'turns'>
@@ -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<void> {
const waiting = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
const elapsed = new Promise<void>((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<NonNullable<StructuredAgentSessionAdapter['settlePermissionTransition']>>[0],
graceMs: number
): Promise<boolean> {
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<StructuredAgentSessionAdapter['awaitStoppedRequestEnd']> {
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<StructuredAgentSessionAdapter['settlePermissionTransition']> {
return (input) => settleAcpPermissionTransition(sessionFor(input.sessionId), input, graceMs())
}
+21 -3
View File
@@ -92,11 +92,29 @@ export class AcpStructuredTurns {
return this.withdrawSteers()
}
whenIdle(): Promise<void> {
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<void> {
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 {
@@ -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<void>
): Promise<AgentSessionDispatchOutcome> {
@@ -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
@@ -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<void>()
const resume = Promise.withResolvers<void>()
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)',
@@ -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<StructuredAgentSessionAdapter['dispatch']> {
let ranOptions: Record<string, string> = {}
const outcome = await dispatchClaudeTurn(session, input, async () => {
await input.beforeDispatch?.()
try {
input.signal?.throwIfAborted()
// Input joining a running turn, and Orca mail notices, run on the choices Claude already has;
@@ -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)
@@ -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'))
})
}
}
@@ -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<AgentSessionAcquisition> =>
acquireCodexStructuredSession({
@@ -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
@@ -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()
}
@@ -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<typeof codexTurnPermission>,
body?: AgentJournalMessageItem
body?: AgentJournalMessageItem,
mode?: AgentChatPermissionChoice
) => Extract<StructuredAgentSessionInputAdmission, { kind: 'sent' }>,
signal?: AbortSignal
): Promise<unknown> {
let prepared = false
const enqueue: Parameters<StructuredAgentSessionAdmitFreshInput>[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<string, CodexSession>
): NonNullable<StructuredAgentSessionAdapter['permissionModeRequiresAcquisition']> {
return (sessionId, mode) => {
const state = sessions.get(sessionId)?.permissions
return mode === 'own' && !!state && (state.appliedMode ?? state.acquiredMode) !== 'own'
}
}
export function createCodexPermissionTransition(
sessions: ReadonlyMap<string, CodexSession>,
controls: {
cancelTurn: StructuredAgentSessionAdapter['cancelTurn']
awaitStoppedRequestEnd: (sessionId: string, stoppedAt: number) => Promise<void>
}
): NonNullable<StructuredAgentSessionAdapter['settlePermissionTransition']> {
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
)
}
}
+27 -59
View File
@@ -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<CodexSkillReport, 'hasSkillPath'>
@@ -75,40 +54,6 @@ export type CodexTurnHost = {
turnOpenWaits: Pick<CodexTurnOpenWaits, 'wait'>
}
/** 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<Parameters<typeof admitCodexTurnPermission>[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,
@@ -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<Parameters<StructuredAgentSessionAdmitFreshInput>[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
}
}
@@ -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<StructuredAgentSessionAdapter['rewindSupport']> = (
sessionId,
@@ -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<boolean>
dispatch(input: {
sessionId: string
clientMessageId: string
@@ -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<unknown>()
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)
})
@@ -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<typeof open>[0]) => {
if (boundary === 'acquire' || boundary === 'starting-exit') {
@@ -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<typeof host.waitForSendSettlement> | 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) => {
@@ -36,6 +36,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = {
next?: AgentJournalSubmission
) => Promise<void>
immediatePermission?: (sessionId: string) => AgentChatPermissionChoice | null
stopPermissionTransitionChild?: (sessionId: string) => Promise<void>
retainedWork?: (sessionId: string, continuingId: string) => boolean
readiness: (
sessionId: string,
@@ -11,6 +11,7 @@ type HandoverJournal = Pick<UnopenedSendJournal, 'submissions'>
type HandoverInput = {
owner: () => void
unadmitted: boolean
compilerPermission?: 'auto'
signal?: AbortSignal
pending?: () => boolean
finalization: { close: (cause: UnsentSendHold['cause']) => void; completion: Promise<void> }
@@ -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<T>(
@@ -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,
@@ -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<void>
stopPermissionTransitionChild: (sessionId: string) => Promise<void>
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),
@@ -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)
@@ -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<unknown> }
/** 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<StructuredAgentSessionInputAdmission>
export type StructuredAgentSessionAdmitHostInput = (
enqueue: Parameters<StructuredAgentSessionAdmitFreshInput>[0],
signal: AbortSignal | undefined,
prepare: (fence: number) => Promise<AgentJournalMessageItem>
prepare: (fence: number) => Promise<AgentJournalMessageItem>,
kind?: 'fresh' | 'steer'
) => ReturnType<StructuredAgentSessionAdmitFreshInput>
export type StructuredAgentSessionContinueInput = (
signal: AbortSignal | undefined,
rebind: (fence: number) => Promise<void>
rebind: (fence: number) => Promise<void>,
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<AgentSessionDispatchOutcome, { state: 'rejected' }>) {
constructor(
readonly outcome?: Extract<AgentSessionDispatchOutcome, { state: 'rejected' }>,
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<void> {
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
}
@@ -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<ReturnType<typeof startThatFailedWhileQueued>>
@@ -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)
)
)
@@ -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<typeof continuingStructuredAgentSessionInput>[0] | undefined,
journal: Parameters<typeof continuingStructuredAgentSessionCompilerPermission>[0] | undefined,
startedFor?: string
): 'auto' | undefined {
return journal && startedFor && continuingStructuredAgentSessionInput(journal, startedFor)
? 'auto'
return journal && startedFor
? continuingStructuredAgentSessionCompilerPermission(journal, startedFor)
: undefined
}
@@ -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)
})
})
@@ -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 } }
}
@@ -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(
<NativeChatComposerPermissionPicker surface={options} snapshot={snapshot()} agent={agent} />
)
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe('true')
for (const report of [mode, null, mode, undefined]) {
rerender(
<NativeChatComposerPermissionPicker
surface={options}
snapshot={snapshot(report)}
agent={agent}
/>
)
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(
<NativeChatComposerPermissionPicker surface={options} snapshot={snapshot()} agent={agent} />
)
expect(screen.getByTestId('permission-trigger').getAttribute('data-disabled')).toBe(null)
for (const report of [mode, null, mode, undefined]) {
rerender(
<NativeChatComposerPermissionPicker
surface={options}
snapshot={snapshot(mode)}
snapshot={snapshot(report)}
agent={agent}
/>
)
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(
<NativeChatComposerPermissionPicker
surface={options}
snapshot={snapshot(mode)}
agent={agent}
/>
)
fireEvent.click(option('full-access'))
expect(options.setOption).toHaveBeenCalledExactlyOnceWith('permissionMode', 'full-access')
})
it('renders nothing without a permission descriptor', () => {
const { container } = render(
@@ -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 (
<DropdownMenu>
@@ -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)
})
@@ -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<boolean> | 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({
@@ -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' &&
@@ -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]) {
@@ -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
}
}
@@ -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' } })