mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -2650,6 +2650,9 @@
|
||||
"title": "Spracheingabe",
|
||||
"toggleMode": "Umschalten",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Eigene Einstellungen der Agenten"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -145,6 +145,9 @@
|
||||
"speechModelHeading": "SPEECH MODEL",
|
||||
"speechModel": "Speech Model",
|
||||
"loading": "Loading voice settings…"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Agents' own settings"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -2721,6 +2721,9 @@
|
||||
"title": "Voz",
|
||||
"toggleMode": "Alternar",
|
||||
"updateFailed": "No se pudo actualizar"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Configuración propia de los agentes"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2721,6 +2721,9 @@
|
||||
"title": "Voix",
|
||||
"toggleMode": "Bascule",
|
||||
"updateFailed": "Impossible de mettre à jour"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Paramètres propres aux agents"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2646,6 +2646,9 @@
|
||||
"title": "音声",
|
||||
"toggleMode": "トグル",
|
||||
"updateFailed": "更新できませんでした"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Agent 独自の設定"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2646,6 +2646,9 @@
|
||||
"title": "음성",
|
||||
"toggleMode": "토글",
|
||||
"updateFailed": "업데이트하지 못했습니다."
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "에이전트 자체 설정"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2721,6 +2721,9 @@
|
||||
"title": "Voz",
|
||||
"toggleMode": "Alternar",
|
||||
"updateFailed": "Não foi possível atualizar"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Configurações próprias dos agentes"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2792,6 +2792,9 @@
|
||||
"title": "Голос",
|
||||
"toggleMode": "Переключение",
|
||||
"updateFailed": "Не удалось обновить"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Собственные настройки агентов"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2650,6 +2650,9 @@
|
||||
"title": "Ses",
|
||||
"toggleMode": "Aç/kapat",
|
||||
"updateFailed": "Güncellenemedi"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "Ajanların kendi ayarları"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -2646,6 +2646,9 @@
|
||||
"title": "語音",
|
||||
"toggleMode": "切換",
|
||||
"updateFailed": "無法更新"
|
||||
},
|
||||
"agents": {
|
||||
"permissionOwnAll": "各代理程式自身的設定"
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+100
@@ -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)
|
||||
})
|
||||
|
||||
+16
-16
@@ -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') {
|
||||
|
||||
+1
-50
@@ -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)
|
||||
|
||||
+37
-5
@@ -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
|
||||
}
|
||||
|
||||
+81
-36
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -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>
|
||||
|
||||
+7
-5
@@ -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' &&
|
||||
|
||||
+18
-4
@@ -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' } })
|
||||
|
||||
Reference in New Issue
Block a user