From 532f6a0e00a5940b260be6b0d1c8e088cc4a1c1a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:50:00 -0700 Subject: [PATCH] Ask before Codex workspace edits and apply permissions to fresh turns (#31299) * Apply Codex Ask permissions at each fresh turn * fix: admit fresh Codex input with current host permissions * fix(native-chat): finish cancelled Codex input under its owner * fix(native-chat): preserve mobile type boundary for input finalization * test(native-chat): bound input finalization work at scale * Fix cancellation result fences and exercise host proofs * Fix input finalization type contracts * Remove obsolete Portuguese permission description * Preserve proven Stop outcomes during input finalization * Preserve input handover proofs with session-ending Stop * Preserve pending input recovery before lifecycle cancellation * Assert one recovery card across overlapping close requests * Preserve unwritten inputs across shared-agent Close preparation * Narrow cancelled dispatch to its rejected outcome * Import Codex app-server types from their colocated modules * Use standard Ask policy for fresh Codex chat input * Deliver applied Codex permission reports to the host * Cover applied reports with pending settlement waiters at scale * Fix settlement waiter timer portability on mobile * Keep pre-send exit failures with their original input result --- .../acp-structured-host-option-hang.test.ts | 36 +- .../codex/codex-structured-auto-sandbox.ts | 56 ++ ...codex-structured-dispatch-boundary.test.ts | 19 +- .../codex-structured-dispatch-test-support.ts | 3 + ...codex-structured-launch-resolution.test.ts | 7 +- .../codex/codex-structured-message-options.ts | 72 ++ ...codex-structured-permission-policy.test.ts | 7 +- .../codex-structured-permission-policy.ts | 16 +- .../codex-structured-prompt-ownership.ts | 31 + ...ex-structured-real-cli-permissions.test.ts | 16 +- .../codex-structured-real-cli-visuals.test.ts | 10 +- .../codex/codex-structured-sandbox-policy.ts | 25 + .../codex-structured-session-adapter.test.ts | 5 +- .../codex/codex-structured-session-adapter.ts | 61 +- .../codex/codex-structured-session-options.ts | 2 +- .../codex/codex-structured-session-start.ts | 7 + .../codex/codex-structured-session-state.ts | 13 + .../codex-structured-thread-open.test.ts | 103 ++- .../codex/codex-structured-thread-open.ts | 18 +- .../codex-structured-turn-permission.test.ts | 256 ++++++ .../codex/codex-structured-turn-permission.ts | 120 +++ src/main/codex/codex-structured-turn-start.ts | 132 +-- .../journal-dispatch-reducer.ts | 3 + .../journal-unsent-send-hold.ts | 4 +- ...structured-agent-session-acquire-aborts.ts | 20 +- ...structured-agent-session-adapter-router.ts | 7 + .../structured-agent-session-adapter.ts | 9 + ...ured-agent-session-attach-orchestration.ts | 6 +- .../structured-agent-session-child-exit.ts | 6 +- ...agent-session-claude-stop-note-end.test.ts | 68 ++ ...gent-session-codex-input-admission.spec.ts | 356 ++++++++ ...t-session-codex-input-finalization.spec.ts | 760 +++++++++++++++++ ...red-agent-session-codex-input-host.test.ts | 796 ++++++++++++++++++ ...t-session-codex-input-perf-test-support.ts | 159 ++++ ...gent-session-conversation-lifetime-host.ts | 36 +- ...red-agent-session-conversation-lifetime.ts | 45 +- .../structured-agent-session-current-work.ts | 15 +- ...gent-session-dead-generation-settlement.ts | 6 +- ...ctured-agent-session-delivery-loop-deps.ts | 13 +- .../structured-agent-session-delivery-loop.ts | 37 +- ...structured-agent-session-event-recovery.ts | 9 +- .../structured-agent-session-handing-over.ts | 114 ++- .../structured-agent-session-handover.ts | 241 +++--- .../structured-agent-session-host-delivery.ts | 90 +- .../structured-agent-session-host-lifetime.ts | 66 +- .../structured-agent-session-host-teardown.ts | 4 + ...structured-agent-session-host-test-data.ts | 34 + .../structured-agent-session-host-types.ts | 52 ++ ...tructured-agent-session-input-admission.ts | 39 + ...structured-agent-session-input-handover.ts | 261 ++++++ ...ured-agent-session-late-settlement.test.ts | 84 ++ ...uctured-agent-session-launch-permission.ts | 40 + ...tured-agent-session-leftover-settlement.ts | 17 +- ...ured-agent-session-operation-settlement.ts | 50 ++ ...tured-agent-session-outgrown-child.test.ts | 17 +- ...structured-agent-session-outgrown-child.ts | 29 +- .../structured-agent-session-provider-open.ts | 6 +- .../structured-agent-session-ran-options.ts | 2 +- ...structured-agent-session-start-attempts.ts | 6 +- ...agent-session-stopped-history-selection.ts | 4 +- ...tructured-agent-session-unfinished-work.ts | 11 +- ...tructured-agent-launch-parity.test.ts.snap | 10 +- .../structured-agent-runtime-registrations.ts | 2 +- .../NativeChatPermissionModePicker.test.tsx | 2 +- .../native-chat-permission-mode-labels.ts | 6 - src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/pt.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - src/shared/agent-session-options-result.ts | 2 +- src/shared/promise-settlement-waiters.ts | 11 +- ...ed-agent-session-permission-option.test.ts | 2 +- ...uctured-agent-session-permission-option.ts | 10 +- ...permission-report-publication.unit.test.ts | 142 ++++ 77 files changed, 4344 insertions(+), 387 deletions(-) create mode 100644 src/main/codex/codex-structured-auto-sandbox.ts create mode 100644 src/main/codex/codex-structured-sandbox-policy.ts create mode 100644 src/main/codex/codex-structured-turn-permission.test.ts create mode 100644 src/main/codex/codex-structured-turn-permission.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-perf-test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 6f6f9524c5c..7adc92fe784 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -1,3 +1,4 @@ +import { structuredAgentSessionHandingOver } from '../native-chat/agent-session-wire/structured-agent-session-handing-over' import { afterEach, describe, expect, it, vi } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { @@ -6,7 +7,13 @@ 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 { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' -import { framesOf, openAttachedHostRig, send, stop } from './acp-structured-host.test-support' +import { + framesOf, + hello, + openAttachedHostRig, + send, + stop +} from './acp-structured-host.test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { STOP_NOTE_CANCELLATION_REQUESTED } from '../native-chat/agent-session-wire/structured-agent-session-turn-stop-notes' @@ -56,12 +63,37 @@ describe('a Grok model pick Grok never answers during send', () => { script: (agent) => agent.on('session/set_config_option', () => {}) }) expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) - await send(host, 'hello') + const dispatch = vi.spyOn(rig.adapter, 'dispatch') + const sending = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello, + userSend: true + }) + if (!sending.ok) { + throw new Error(JSON.stringify(sending.refusal)) + } + const id = sending.value.clientMessageId + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + const original = structuredAgentSessionHandingOver(journal).get(id)?.input await expect(host.close(SESSION, 'user-close')).resolves.toBeUndefined() + await original?.finalization.completion + await Promise.allSettled(dispatch.mock.results.map((result) => result.value)) const [frame] = framesOf(rig.child(), 'session/set_config_option') rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + await rig.settle() + await host.flushStreamedEvents(SESSION) + await journal['queue'].drain() expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) + expect(original).toBeDefined() + expect(structuredAgentSessionHandingOver(journal).has(id)).toBe(false) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: id, body: { blocks: [{ text: 'hello' }] } } + ]) + expect(journal.submission(id)).toMatchObject({ + keptAsQueuedMessageId: id, + rejection: { kind: 'chatClosed' } + }) }) it('re-establishes opening A after uncertain B without adopting B’s late reply', async () => { diff --git a/src/main/codex/codex-structured-auto-sandbox.ts b/src/main/codex/codex-structured-auto-sandbox.ts new file mode 100644 index 00000000000..93998911fe2 --- /dev/null +++ b/src/main/codex/codex-structured-auto-sandbox.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types' +import type { CodexSandboxPolicy } from './codex-structured-sandbox-policy' + +const configuration = z.object({ + config: z.object({ + sandbox_workspace_write: z + .object({ + writable_roots: z.array(z.string()).default([]), + network_access: z.boolean().default(false), + exclude_tmpdir_env_var: z.boolean().default(false), + exclude_slash_tmp: z.boolean().default(false) + }) + .nullish() + }) +}) +const requirements = z.object({ requirements: z.record(z.string(), z.unknown()).nullable() }) + +/** Only the legacy path with observable inputs is projected; managed/backend compilation stays Codex's. */ +export async function readCodexAutoSandbox( + connection: Pick, + input: { cwd: string; visualsFolder?: string }, + platform: NodeJS.Platform = process.platform +): Promise { + // Windows backend availability and managed selection are not exposed by config/read. + if (platform === 'win32') { + return undefined + } + try { + const [rawConfig, rawRequirements] = await Promise.all([ + connection.request('config/read', { cwd: input.cwd }, { timeoutMs: 2_000 }), + connection.request('configRequirements/read', {}, { timeoutMs: 2_000 }) + ]) + const { config } = configuration.parse(rawConfig) + const { requirements: managed } = requirements.parse(rawRequirements) + // The requirements API omits managed profile definitions and some backend constraints. + if (managed !== null) { + return undefined + } + const workspace = config.sandbox_workspace_write + return { + type: 'workspaceWrite', + writableRoots: [ + ...new Set([ + ...(workspace?.writable_roots ?? []), + ...(input.visualsFolder ? [input.visualsFolder] : []) + ]) + ], + networkAccess: workspace?.network_access ?? false, + excludeTmpdirEnvVar: workspace?.exclude_tmpdir_env_var ?? false, + excludeSlashTmp: workspace?.exclude_slash_tmp ?? false + } + } catch { + return undefined + } +} diff --git a/src/main/codex/codex-structured-dispatch-boundary.test.ts b/src/main/codex/codex-structured-dispatch-boundary.test.ts index 1a50f4f3b2c..41a33330ce8 100644 --- a/src/main/codex/codex-structured-dispatch-boundary.test.ts +++ b/src/main/codex/codex-structured-dispatch-boundary.test.ts @@ -1,3 +1,4 @@ +import { StructuredAgentSessionInputNotAdmitted } from '../native-chat/agent-session-wire/structured-agent-session-input-admission' import { expect, it, vi } from 'vitest' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { @@ -60,7 +61,14 @@ it('stops a refused steer before its fallback write', async () => { } catch (error) { refused.reject(error) } - await expect(sending).rejects.toBe(reason) + await expect(sending).rejects.toBeInstanceOf(StructuredAgentSessionInputNotAdmitted) + await expect(sending).rejects.toMatchObject({ + outcome: { + state: 'rejected', + rejection: { kind: 'cancelled' }, + reason: 'provider_cancelled_before_start' + } + }) expect(connection.calls.some((call) => call.method === 'turn/start')).toBe(false) // The journal keeps stopped sends rejected even if an exact echo arrives. echoUserMessage(connection, { turnId: 'running', itemId: 'stopped', clientId: 'stopped' }) @@ -87,7 +95,14 @@ it('abandons only the stopped message while waiting for a previous turn to open' await new Promise((resolve) => setTimeout(resolve, 10)) const reason = new Error('Stopped local wait') controller.abort(reason) - await expect(stopped).rejects.toBe(reason) + await expect(stopped).rejects.toBeInstanceOf(StructuredAgentSessionInputNotAdmitted) + await expect(stopped).rejects.toMatchObject({ + outcome: { + state: 'rejected', + rejection: { kind: 'cancelled' }, + reason: 'provider_cancelled_before_start' + } + }) expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(1) startTurn(connection, 'opening') echoUserMessage(connection, { turnId: 'opening', itemId: 'first', clientId: 'first' }) diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 6182d2804b6..b8d2f9a4d3b 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,5 +1,6 @@ import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' import { expect, vi } from 'vitest' +import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -95,6 +96,7 @@ export async function acquiredCodexAdapter(input: { settlements: LateSettlement[] sink?: StructuredAgentSessionEventSink requestTimeoutMs?: number + permissionMode?: AgentChatPermissionChoice }): Promise { const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -121,6 +123,7 @@ export async function acquiredCodexAdapter(input: { identity, fence: 7, spawnToken: 'spawn-9', + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), events: input.sink ?? recordingSink() }) return adapter diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 30de6dfb17c..6b01968c856 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -162,7 +162,7 @@ describe('codex structured launch resolution', () => { resumeThreadId: null, permissionPolicy: { approvalPolicy: 'on-request', - sandbox: 'workspace-write', + sandbox: 'read-only', approvalsReviewer: 'user' }, env: { CODEX_HOME: '/home/work/.codex' }, @@ -249,10 +249,7 @@ describe('codex structured launch resolution', () => { }) it.each([ - [ - 'ask', - { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'user' } - ], + ['ask', { approvalPolicy: 'on-request', sandbox: 'read-only', approvalsReviewer: 'user' }], [ 'auto', { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'auto_review' } diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 23b52e79e19..9ce4b68c31a 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -1,3 +1,12 @@ +import { pickedSkillsInText } from '../../shared/structured-agent-session-send-mutation' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { CodexTurnHost } from './codex-structured-turn-start' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { StructuredAgentSessionInputNotAdmitted } from '../native-chat/agent-session-wire/structured-agent-session-input-admission' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { codexDispatchRejection } from './codex-structured-turn-end-settlement' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { readCodexConfiguredLaunchDefaults } from './codex-configured-launch-defaults' @@ -118,3 +127,66 @@ export async function resolvedCodexMessageOptions( } return Object.fromEntries(selected.options) } + +/** Check the live binding after options settle and again whenever the provider is about to write. */ +export async function prepareCodexMessageDispatch( + session: CodexSession, + input: Parameters[0], + registered: () => boolean +) { + const checkAbort = () => { + if (input.signal?.aborted) { + throw new StructuredAgentSessionInputNotAdmitted({ + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact(session.ended ? 'providerExited' : 'cancelled') + ) + }) + } + } + await input.beforeDispatch?.() + checkAbort() + input.onInputPreparation?.() + const turnOptions = await waitForPromiseWithSignal( + resolvedCodexMessageOptions(session, input.executionOptions), + input.signal + ).catch((error: unknown) => { + checkAbort() + throw error + }) + checkAbort() + return { + ...input, + turnOptions, + isCurrent: () => + registered() && + !session.ended && + session.fence === input.fence && + input.isCurrent?.() !== false + } +} + +export function turnInputFor( + host: CodexTurnHost, + body: AgentJournalMessageItem +): Record[] { + const input: Record[] = [] + let text = '' + for (const block of body.blocks as NativeChatBlock[]) { + if (block.type === 'text' && block.text.length > 0) { + input.push({ type: 'text', text: block.text }) + text += `${block.text}\n` + } else if (block.type === 'image-ref' && block.path) { + input.push({ type: 'localImage', path: block.path }) + } else if (block.type === 'image-ref' && block.url) { + input.push({ type: 'image', url: block.url }) + } + } + // Unknown skill paths suppress plain $name resolution, so send only loaded files when known. + for (const skill of pickedSkillsInText(text, body.skills ?? [])) { + if (host.skillReport?.hasSkillPath(skill.path) !== false) { + input.push({ type: 'skill', name: skill.name, path: skill.path }) + } + } + return input +} diff --git a/src/main/codex/codex-structured-permission-policy.test.ts b/src/main/codex/codex-structured-permission-policy.test.ts index 99c5a59f5b3..7feb20be88f 100644 --- a/src/main/codex/codex-structured-permission-policy.test.ts +++ b/src/main/codex/codex-structured-permission-policy.test.ts @@ -6,7 +6,7 @@ const FULL_ACCESS = { sandbox: 'danger-full-access', approvalsReviewer: 'user' } -const ASK = { approvalPolicy: 'on-request', sandbox: 'workspace-write', approvalsReviewer: 'user' } +const ASK = { approvalPolicy: 'on-request', sandbox: 'read-only', approvalsReviewer: 'user' } describe('codexStructuredPermissionPolicy', () => { it('maps Full access to no prompts and no sandbox', () => { @@ -17,9 +17,10 @@ describe('codexStructuredPermissionPolicy', () => { expect(codexStructuredPermissionPolicy('ask')).toEqual(ASK) }) - it('routes Approve for me to Codex auto-review under the Ask sandbox', () => { + it('keeps Approve for me in the configured workspace sandbox', () => { expect(codexStructuredPermissionPolicy('auto')).toEqual({ - ...ASK, + approvalPolicy: 'on-request', + sandbox: 'workspace-write', approvalsReviewer: 'auto_review' }) }) diff --git a/src/main/codex/codex-structured-permission-policy.ts b/src/main/codex/codex-structured-permission-policy.ts index b0f2bf27beb..b450c5ef648 100644 --- a/src/main/codex/codex-structured-permission-policy.ts +++ b/src/main/codex/codex-structured-permission-policy.ts @@ -12,6 +12,11 @@ export type CodexStructuredPermissionPolicy = sandbox: 'workspace-write' approvalsReviewer: CodexApprovalsReviewer } + | { + approvalPolicy: 'on-request' + sandbox: 'read-only' + approvalsReviewer: CodexApprovalsReviewer + } const FULL_ACCESS_POLICY = { approvalPolicy: 'never', @@ -20,14 +25,19 @@ const FULL_ACCESS_POLICY = { } as const // Explicit fields prevent inheriting permissive config or previous thread policies. +/** Workspace writes need approval even when the user's configured sandbox permits them. */ const ASK_POLICY = { approvalPolicy: 'on-request', - // A mode preserves configured roots/network; read-only would add write approvals. - sandbox: 'workspace-write', + sandbox: 'read-only', approvalsReviewer: 'user' } as const -const AUTO_POLICY = { ...ASK_POLICY, approvalsReviewer: 'auto_review' } as const +// A mode preserves configured roots/network; read-only would add write approvals. +const AUTO_POLICY = { + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + approvalsReviewer: 'auto_review' +} as const /** Codex has no edits-only mode; unsupported modes fall back to Ask. */ export function codexStructuredPermissionPolicy( diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 0e525d93d10..775b437be0c 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -1,3 +1,6 @@ +import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import * as turnEnd from './codex-structured-turn-end-settlement' import { AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, @@ -206,3 +209,31 @@ export async function answerCodexStructuredPrompt(input: { throw error } } + +/** The reply acknowledges receipt; the translator observes turn completion separately. */ +export async function compactCodexStructuredSession( + session: CodexSession, + input: Parameters>[0], + requestTimeoutMs: number | undefined +): ReturnType> { + session.translator?.beginCommand(input.command) + try { + await session.connection.request( + 'thread/compact/start', + { threadId: session.threadId }, + { timeoutMs: requestTimeoutMs } + ) + return { state: 'accepted', providerIdentity: null } + } catch (error) { + session.translator?.forgetCommand(input.command.turnId) + if (isCodexAppServerRequestError(error)) { + return { + state: 'rejected', + ...turnEnd.codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) + ) + } + } + throw error + } +} diff --git a/src/main/codex/codex-structured-real-cli-permissions.test.ts b/src/main/codex/codex-structured-real-cli-permissions.test.ts index fbb0c8f808a..43516a3c03b 100644 --- a/src/main/codex/codex-structured-real-cli-permissions.test.ts +++ b/src/main/codex/codex-structured-real-cli-permissions.test.ts @@ -84,9 +84,15 @@ function effectivePolicy(mode: AgentChatPermissionMode) { sandbox: { type: 'dangerFullAccess' }, approvalsReviewer: 'user' } - : { - approvalPolicy: 'on-request', - sandbox: { type: 'workspaceWrite' }, - approvalsReviewer: mode === 'auto' ? 'auto_review' : 'user' - } + : mode === 'ask' + ? { + approvalPolicy: 'on-request', + sandbox: { type: 'readOnly' }, + approvalsReviewer: 'user' + } + : { + approvalPolicy: 'on-request', + sandbox: { type: 'workspaceWrite' }, + approvalsReviewer: mode === 'auto' ? 'auto_review' : 'user' + } } diff --git a/src/main/codex/codex-structured-real-cli-visuals.test.ts b/src/main/codex/codex-structured-real-cli-visuals.test.ts index dccb78bc3ce..7bbc3f18390 100644 --- a/src/main/codex/codex-structured-real-cli-visuals.test.ts +++ b/src/main/codex/codex-structured-real-cli-visuals.test.ts @@ -9,7 +9,7 @@ import { withCodexVisualsThreadConfig } from './codex-structured-visuals' import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' -const MANUAL = codexStructuredPermissionPolicy('ask') +const WORKSPACE_POLICY = codexStructuredPermissionPolicy('auto') describe.skipIf(!enabled)('Codex real app-server chat visuals', () => { it('lists the visuals skill and grants the chat folder beside the user writable roots', async () => { @@ -38,7 +38,7 @@ describe.skipIf(!enabled)('Codex real app-server chat visuals', () => { connection, { cwd, - permissionPolicy: MANUAL, + permissionPolicy: WORKSPACE_POLICY, visuals: { folder, skill: { pluginDir, skillsRoot: join(pluginDir, 'skills') } } }, { sessionId: 'real-codex-visuals' } @@ -51,7 +51,11 @@ describe.skipIf(!enabled)('Codex real app-server chat visuals', () => { ) const opened = await connection.request( 'thread/start', - { cwd, ...MANUAL, ...(launch.threadConfig ? { config: launch.threadConfig } : {}) }, + { + cwd, + ...WORKSPACE_POLICY, + ...(launch.threadConfig ? { config: launch.threadConfig } : {}) + }, { timeoutMs: 20_000 } ) expect(opened).toMatchObject({ diff --git a/src/main/codex/codex-structured-sandbox-policy.ts b/src/main/codex/codex-structured-sandbox-policy.ts new file mode 100644 index 00000000000..1dc3475f669 --- /dev/null +++ b/src/main/codex/codex-structured-sandbox-policy.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +const sandboxPolicy = z.discriminatedUnion('type', [ + z.looseObject({ type: z.literal('readOnly'), networkAccess: z.boolean().optional() }), + z.looseObject({ type: z.literal('dangerFullAccess') }), + z.looseObject({ + type: z.literal('workspaceWrite'), + writableRoots: z.array(z.string()), + networkAccess: z.boolean(), + excludeTmpdirEnvVar: z.boolean(), + excludeSlashTmp: z.boolean() + }), + z.looseObject({ + type: z.literal('externalSandbox'), + networkAccess: z.enum(['enabled', 'restricted']) + }) +]) + +export type CodexSandboxPolicy = z.infer + +/** Replay the provider's complete policy, including fields a newer binary may add. */ +export function readCodexSandboxPolicy(value: unknown): CodexSandboxPolicy | undefined { + const parsed = sandboxPolicy.safeParse(value) + return parsed.success ? parsed.data : undefined +} diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 50e5bce64d6..cef2a80aa50 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -164,7 +164,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { params: { cwd: '/work/repo', approvalPolicy: 'on-request', - sandbox: 'workspace-write', + sandbox: 'read-only', approvalsReviewer: 'user', // Explicit values survive inherit = core. config: { @@ -473,6 +473,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { { threadId: THREAD_ID, clientUserMessageId: 'client-1', + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandboxPolicy: { type: 'readOnly' }, input: [ { type: 'text', text: 'ship it' }, { type: 'localImage', path: '/tmp/shot.png' }, diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 6c7841ce2c6..0db36b8d660 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,8 +1,6 @@ import { projectCodexSessionOptions } from './codex-structured-service-tier' import * as codexRewind from './codex-structured-rewind' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error' import type { AgentSessionAcquisition, AgentSessionDispatchOutcome, @@ -13,7 +11,6 @@ import type { import type { StructuredAgentSessionProviderOpenInput } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' -import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { @@ -21,7 +18,7 @@ import { prepareLiveCodexSessionOptions, readLiveCodexSessionOptions } from './codex-structured-session-options' -import { resolvedCodexMessageOptions } from './codex-structured-message-options' +import { prepareCodexMessageDispatch } from './codex-structured-message-options' import { CodexAcquisitionRegistry, requireLiveCodexSession, @@ -43,6 +40,7 @@ import { changeCodexThreadGoalWithSettings } from './codex-structured-thread-goa import { startCodexTerminalStopProbe } from './codex-background-terminals' import { codexBackgroundTaskStops, stopCodexBackgroundTasks } from './codex-background-task-stops' import { + compactCodexStructuredSession, answerCodexStructuredPrompt, cancelCodexStructuredTurn } from './codex-structured-prompt-ownership' @@ -90,6 +88,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap readonly atRestCommands: CodexStructuredSessionAdapterDeps['atRestCommands'] + permissionModeRequiresAcquisition = (): boolean => false + acquire = (input: StructuredAgentSessionAcquireInput): Promise => acquireCodexStructuredSession({ input, @@ -233,15 +233,26 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap const session = this.session(input.sessionId) session.dispatchPending = true try { - await input.beforeDispatch?.() - input.signal?.throwIfAborted() - input.onInputPreparation?.() - const turnOptions = await waitForPromiseWithSignal( - resolvedCodexMessageOptions(session, input.executionOptions), - input.signal + const outcome = await dispatchCodexTurn( + session, + await prepareCodexMessageDispatch( + session, + input, + () => this.sessions.get(input.sessionId) === session + ), + this.deps.requestTimeoutMs ) - input.signal?.throwIfAborted() - return await dispatchCodexTurn(session, { ...input, turnOptions }, this.deps.requestTimeoutMs) + this.deps.onEvent?.({ + type: 'options-reported', + sessionId: input.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + reportedOptions: { + model: '', + reportedPermissionMode: session.reportedOptions.reportedPermissionMode + } + }) + return outcome } finally { session.dispatchPending = false settleCodexRequestEndWaiters(session) @@ -268,30 +279,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap recoverRewind: NonNullable = (input) => codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs) - /** The reply acknowledges receipt; the translator observes turn completion separately. */ - compact: NonNullable = async (input) => { - const session = this.session(input.sessionId) - session.translator?.beginCommand(input.command) - try { - await session.connection.request( - 'thread/compact/start', - { threadId: session.threadId }, - { timeoutMs: this.deps.requestTimeoutMs } - ) - return { state: 'accepted', providerIdentity: null } - } catch (error) { - session.translator?.forgetCommand(input.command.turnId) - if (isCodexAppServerRequestError(error)) { - return { - state: 'rejected', - ...turnEnd.codexDispatchRejection( - agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) - ) - } - } - throw error - } - } + compact: NonNullable = (input) => + compactCodexStructuredSession(this.session(input.sessionId), input, this.deps.requestTimeoutMs) changeThreadGoal: NonNullable = (input) => { const session = this.session(input.sessionId) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index c87dad1b06c..42ed34ec6ab 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -28,7 +28,7 @@ export function restoredCodexSessionOptions( return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) } -/** Startup reports the thread's defaults together with the saved next-turn intent. */ +/** Report current child evidence together with the saved next-turn intent. */ export function reportedCodexSessionOptions( session: CodexSession ): AgentSessionOptionsResult['current'] { diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 74163e7a672..48f78ad3dc4 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -143,6 +143,13 @@ export async function openCodexStructuredSession( ...codexSessionLifecycle(input.fence, generation), threadId: opened.threadId, historyMode: opened.historyMode, + permissions: { + acquiredMode: input.permissionMode ?? 'ask', + ...(opened.sandbox ? { acquiredSandbox: opened.sandbox } : {}), + launchSandboxIntact: true, + cwd: launch.cwd, + ...(launch.visuals ? { visualsFolder: launch.visuals.folder } : {}) + }, activeTurnIds: new Set(), abortedTurnIds: new Set(), prompts: attempt.window.prompts, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index d662f369a9c..8bc6ae172c9 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -31,6 +31,8 @@ import type { } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionProviderOpen } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' +import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' +import type { CodexSandboxPolicy } from './codex-structured-sandbox-policy' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess & { @@ -144,6 +146,16 @@ export type CodexSession = { acquisitionGeneration: string threadId: string historyMode?: 'legacy' | 'paginated' + /** Provider facts from this acquisition, not another copy of the chat's selected mode. */ + permissions?: { + acquiredMode: AgentChatPermissionChoice + acquiredSandbox?: CodexSandboxPolicy + launchSandboxIntact: boolean + cwd: string + visualsFolder?: string + preparedMode?: AgentChatPermissionChoice + preparedAuto?: CodexSandboxPolicy + } /** Wire-observed open primary turns; rewind and untargeted Stop use these before journal admission. */ activeTurnIds?: Set /** Interrupt-acknowledged turns cannot accept steers while awaiting turn/completed. */ @@ -152,6 +164,7 @@ export type CodexSession = { dispatchPending?: boolean prompts: CodexAcquisitionWindow['prompts'] options: Map + /** Startup reports and accepted fresh-turn policies describe this child, never saved intent. */ reportedOptions: { reportedPermissionMode?: string | null model?: string diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index f542f1bc734..9b1610ddfa7 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -14,37 +14,86 @@ function connectionFor( } describe('openCodexThread', () => { - it.each([ - ['workspaceWrite', 'auto'], - ['readOnly', 'read-only'], - ['read-only', 'read-only'] - ])('leaves own policy omitted on start/resume and reports %s', async (sandboxType, expected) => { - const request = vi.fn(async () => ({ - thread: { id: 'own-thread' }, - approvalPolicy: 'on-request', - sandbox: { type: sandboxType }, - approvalsReviewer: 'auto_review' - })) - for (const resumeThreadId of [null, 'own-thread']) { + it('retains the compiled Auto sandbox on start and resume', async () => { + const sandbox = { + type: 'workspaceWrite', + writableRoots: ['/workspace', '/extra-root'], + networkAccess: true, + excludeTmpdirEnvVar: true, + excludeSlashTmp: true + } + const connection = connectionFor(vi.fn(async () => ({ thread: { id: 'thread' }, sandbox }))) + for (const resumeThreadId of [null, 'thread']) { const opened = await openCodexThread( - connectionFor(request), - { cwd: '/workspace', resumeThreadId }, + connection, + { + cwd: '/workspace', + resumeThreadId, + permissionPolicy: codexStructuredPermissionPolicy('auto') + }, 2_000 ) - expect(opened.reportedPermissionMode).toBe(expected) + expect(opened.sandbox).toEqual(sandbox) } - expect(request).toHaveBeenNthCalledWith( - 1, - 'thread/start', - { cwd: '/workspace' }, - { timeoutMs: 2_000 } - ) - expect(request).toHaveBeenNthCalledWith( - 2, - 'thread/resume', - { cwd: '/workspace', threadId: 'own-thread', excludeTurns: true }, - { timeoutMs: 2_000 } + }) + + it.each([ + ['workspaceWrite', 'on-request', 'auto_review', 'auto'], + ['readOnly', 'on-request', 'auto_review', 'read-only'], + ['read-only', 'on-request', 'auto_review', 'read-only'], + ['readOnly', 'on-request', 'user', 'ask'], + ['dangerFullAccess', 'never', 'user', 'full-access'], + ['workspaceWrite', 'on-request', 'user', 'ask'], + ['readOnly', 'untrusted', 'user', 'read-only'], + ['dangerFullAccess', 'never', 'auto_review', 'full-access'], + ['workspaceWrite', 'on-request', undefined, null], + [undefined, undefined, undefined, null] + ])( + 'leaves own policy omitted on start/resume and reports %s/%s/%s', + async (sandboxType, approvalPolicy, approvalsReviewer, expected) => { + const request = vi.fn(async () => ({ + thread: { id: 'own-thread' }, + approvalPolicy, + sandbox: sandboxType ? { type: sandboxType } : undefined, + approvalsReviewer + })) + for (const resumeThreadId of [null, 'own-thread']) { + const opened = await openCodexThread( + connectionFor(request), + { cwd: '/workspace', resumeThreadId }, + 2_000 + ) + expect(opened.reportedPermissionMode).toBe(expected) + } + expect(request).toHaveBeenNthCalledWith( + 1, + 'thread/start', + { cwd: '/workspace' }, + { timeoutMs: 2_000 } + ) + expect(request).toHaveBeenNthCalledWith( + 2, + 'thread/resume', + { cwd: '/workspace', threadId: 'own-thread', excludeTurns: true }, + { timeoutMs: 2_000 } + ) + } + ) + + it('recognizes snake-case exact settings without changing the custom-policy fallback', async () => { + const request = vi.fn(async () => ({ + thread: { id: 'configured' }, + approval_policy: 'on-request', + approvals_reviewer: 'user', + sandbox: 'read-only' + })) + const opened = await openCodexThread( + connectionFor(request), + { cwd: '/workspace', resumeThreadId: 'configured' }, + 2_000 ) + expect(opened.reportedPermissionMode).toBe('ask') + expect(request.mock.calls).toHaveLength(1) }) it('applies the resolved permission policy when starting and resuming a thread', async () => { @@ -107,7 +156,7 @@ describe('openCodexThread', () => { const params = request.mock.calls[0]?.[1] ?? {} expect(params).toMatchObject({ approvalPolicy: 'on-request', - sandbox: 'workspace-write', + sandbox: 'read-only', approvalsReviewer: 'user' }) expect(Object.keys(params)).toEqual( diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 6df57a59456..6bb6d5abc68 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -5,6 +5,7 @@ import { isCodexAppServerRequestError } from './app-server/codex-app-server-requ import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { readCodexThreadId } from './codex-structured-thread-facts' +import { readCodexSandboxPolicy, type CodexSandboxPolicy } from './codex-structured-sandbox-policy' export type CodexOpenedThread = { threadId: string @@ -16,6 +17,7 @@ export type CodexOpenedThread = { effort?: string | null /** Present, including null, only when this app-server reports the effective tier. */ serviceTier?: string | null + sandbox?: CodexSandboxPolicy } function nonEmptyString(value: unknown): string | null { @@ -129,6 +131,7 @@ export async function openCodexThread( const effort = nonEmptyString(result.reasoningEffort) const serviceTierKnown = Object.hasOwn(result, 'serviceTier') const serviceTier = nonEmptyString(result.serviceTier) + const sandbox = readCodexSandboxPolicy(result.sandbox) return { threadId, reportedPermissionMode: reportedCodexPermissionMode(result), @@ -139,17 +142,30 @@ export async function openCodexThread( : {}), ...(model ? { model } : {}), ...(effort || result.reasoningEffort === null ? { effort } : {}), + ...(sandbox ? { sandbox } : {}), ...(serviceTierKnown ? { serviceTier } : {}) } } /** Unrecognized policies remain the agent's settings rather than a guessed Orca mode. */ -function reportedCodexPermissionMode(reply: Record): string | null { +export function reportedCodexPermissionMode(reply: Record): string | null { const approval = reply.approvalPolicy ?? reply.approval_policy const reviewer = reply.approvalsReviewer ?? reply.approvals_reviewer const sandbox = reply.sandbox const sandboxType = typeof sandbox === 'object' && sandbox !== null && 'type' in sandbox ? sandbox.type : sandbox + const readOnly = sandboxType === 'readOnly' || sandboxType === 'read-only' + const workspace = sandboxType === 'workspaceWrite' || sandboxType === 'workspace-write' + const full = sandboxType === 'dangerFullAccess' || sandboxType === 'danger-full-access' + if (approval === 'on-request' && reviewer === 'user' && readOnly) { + return 'ask' + } + if (approval === 'on-request' && reviewer === 'auto_review' && workspace) { + return 'auto' + } + if (approval === 'never' && reviewer === 'user' && full) { + return 'full-access' + } if (sandboxType === 'readOnly' || sandboxType === 'read-only') { return 'read-only' } diff --git a/src/main/codex/codex-structured-turn-permission.test.ts b/src/main/codex/codex-structured-turn-permission.test.ts new file mode 100644 index 00000000000..566540b393a --- /dev/null +++ b/src/main/codex/codex-structured-turn-permission.test.ts @@ -0,0 +1,256 @@ +import { + adapterFor, + acquireReadyCodexForTest, + identityFor, + answerWithOpenedTurn, + fakeCodex, + USER_MESSAGE, + THREAD_ID +} from './codex-structured-session-adapter-fixture' +import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error' +import { expect, it, vi } from 'vitest' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import { + acquiredCodexAdapter, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + fakeCodexAppServer, + startTurn +} from './codex-structured-dispatch-test-support' + +const workspace = { + writable_roots: ['/extra'], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true +} +const sandbox = { + type: 'workspaceWrite', + writableRoots: ['/extra'], + networkAccess: true, + excludeTmpdirEnvVar: true, + excludeSlashTmp: true +} + +it('rereads the host choice after preparation and keeps the exact configured Auto policy', async () => { + const platform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + let nextTurn = 0 + const configuration = Promise.withResolvers() + const codex = fakeCodexAppServer({ + 'config/read': () => configuration.promise, + 'configRequirements/read': () => ({ requirements: null }), + 'turn/start': () => ({ turn: { id: `fresh-${++nextTurn}` } }) + }) + const adapter = await acquiredCodexAdapter({ + codex, + settlements: [], + permissionMode: 'full-access' + }) + const connection = codex.connections[0]! + let mode: AgentChatPermissionMode = 'auto' + const send = (id: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + admitFreshInput: async (enqueue) => enqueue(mode, false), + isCurrent: () => true + }) + const end = () => { + startTurn(connection, `fresh-${nextTurn}`) + connection.handlers.onNotification?.('turn/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: `fresh-${nextTurn}`, status: 'completed' } + }) + } + try { + const sending = send('latest-choice') + await vi.waitFor(() => + expect(connection.calls.some((c) => c.method === 'configRequirements/read')).toBe(true) + ) + mode = 'ask' + configuration.resolve({ config: { sandbox_workspace_write: workspace } }) + expect(await sending).toMatchObject({ state: 'admitted' }) + expect(connection.calls.at(-1)?.params).toMatchObject({ + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandboxPolicy: { type: 'readOnly' } + }) + end() + mode = 'auto' + expect(await send('configured-auto')).toMatchObject({ state: 'admitted' }) + expect(connection.calls.at(-1)?.params).toMatchObject({ + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandboxPolicy: sandbox + }) + end() + codex.routes['config/read'] = () => ({ + config: { + sandbox_workspace_write: { + ...workspace, + writable_roots: ['/new'], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false + } + } + }) + expect(await send('updated-config')).toMatchObject({ state: 'admitted' }) + expect(connection.calls.at(-1)?.params?.sandboxPolicy).toEqual({ + ...sandbox, + writableRoots: ['/new'], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + }) + end() + } finally { + await adapter.closeAll() + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + } +}) + +it.each([null])('omits own policy on fresh input after opening %s', async (resumeThreadId) => { + const codex = fakeCodex() + const configured = () => ({ + thread: { id: THREAD_ID }, + model: 'gpt-live', + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: { type: 'workspaceWrite', writableRoots: ['/configured'] } + }) + codex.routes['thread/start'] = codex.routes['thread/resume'] = configured + const adapter = adapterFor(codex, { resumeThreadId }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'own-child', + permissionMode: 'own' + }) + const connection = codex.connections[0]! + let mode: 'own' | 'full-access' = 'own' + const send = (id: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + fence: 7, + body: USER_MESSAGE, + admitFreshInput: async (enqueue) => enqueue(mode, false) + }) + try { + expect((await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current).toMatchObject( + { reportedPermissionMode: 'ask' } + ) + for (const [index, choice] of ['own', 'full-access', 'own'].entries()) { + mode = choice === 'own' ? 'own' : 'full-access' + codex.routes['turn/start'] = answerWithOpenedTurn(codex, `own-${index}`) + expect(await send(`original-${index}`)).toMatchObject({ state: 'admitted' }) + const params = connection.calls.findLast((call) => call.method === 'turn/start')!.params! + if (mode === 'own') { + expect(params).not.toHaveProperty('approvalPolicy') + expect(params).not.toHaveProperty('approvalsReviewer') + expect(params).not.toHaveProperty('sandboxPolicy') + } else { + expect(params).toMatchObject({ + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandboxPolicy: { type: 'dangerFullAccess' } + }) + } + expect(params.clientUserMessageId).toBe(`original-${index}`) + expect( + (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current + ).toMatchObject({ reportedPermissionMode: index === 0 ? 'ask' : 'full-access' }) + connection.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: `own-${index}`, status: 'completed' } + }) + } + const opened = connection.calls.find((call) => call.method.startsWith('thread/'))!.params! + 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(codex.connections).toHaveLength(1) + } finally { + await adapter.closeAll() + } +}) + +it.each(['accepted', 'refused', 'transport'] as const)( + 'updates applied evidence only for exact successful fresh input: %s', + async (outcome) => { + const answer = Promise.withResolvers() + const codex = fakeCodex({ 'turn/start': () => answer.promise }) + codex.routes['thread/start'] = () => ({ + thread: { id: THREAD_ID }, + model: 'gpt-live', + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: { type: 'workspaceWrite' } + }) + const adapter = adapterFor(codex) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'applied-child', + permissionMode: 'auto' + }) + let requested: AgentChatPermissionMode = 'ask' + const sending = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'exact-applied', + fence: 7, + body: USER_MESSAGE, + admitFreshInput: async (enqueue) => enqueue(requested, false) + }) + const finished = sending.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + const connection = codex.connections[0]! + try { + await vi.waitFor(() => + expect(connection.calls.some((call) => call.method === 'turn/start')).toBe(true) + ) + expect(connection.calls.find((call) => call.method === 'turn/start')?.params).toMatchObject({ + approvalPolicy: 'on-request', + sandboxPolicy: { type: 'readOnly' } + }) + startTurn(connection, 'applied-turn') + expect( + (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current + ).toMatchObject({ reportedPermissionMode: 'auto' }) + requested = 'full-access' + if (outcome === 'accepted') { + answer.resolve({ turn: { id: 'applied-turn' } }) + } else { + answer.reject( + outcome === 'refused' + ? new CodexAppServerRequestError('turn/start', -32600, 'input refused') + : new Error('transport ended after writing') + ) + } + const result = await finished + if (outcome === 'accepted') { + expect(result).toMatchObject({ value: { state: 'admitted' } }) + } else if (outcome === 'refused') { + expect(result).toMatchObject({ value: { state: 'rejected' } }) + } else { + expect(result).toHaveProperty('error') + } + expect( + (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current + ).toMatchObject({ reportedPermissionMode: outcome === 'accepted' ? 'ask' : 'auto' }) + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(1) + expect(codex.connections).toHaveLength(1) + } finally { + answer.resolve({ turn: { id: 'applied-turn' } }) + await finished + await adapter.closeAll() + } + } +) diff --git a/src/main/codex/codex-structured-turn-permission.ts b/src/main/codex/codex-structured-turn-permission.ts new file mode 100644 index 00000000000..bd919e15d69 --- /dev/null +++ b/src/main/codex/codex-structured-turn-permission.ts @@ -0,0 +1,120 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + StructuredAgentSessionInputNotAdmitted, + type StructuredAgentSessionAdmitFreshInput, + type StructuredAgentSessionInputAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-input-admission' +import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode' +import type { CodexSession } from './codex-structured-session-state' +import { readCodexAutoSandbox } from './codex-structured-auto-sandbox' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' +import type { CodexSandboxPolicy } from './codex-structured-sandbox-policy' + +type PermissionHost = { + connection: Pick + permissions?: CodexSession['permissions'] +} + +/** Prepared outside the host serialize so a configuration request never holds up accepting sends. */ +export async function prepareCodexTurnPermission( + session: PermissionHost, + mode: AgentChatPermissionChoice, + signal?: AbortSignal +): Promise { + const state = session.permissions + if (!state) { + return + } + const auto = + mode !== 'auto' + ? undefined + : state.acquiredMode === 'auto' && state.launchSandboxIntact + ? state.acquiredSandbox + : await waitForPromiseWithSignal(readCodexAutoSandbox(session.connection, state), signal) + signal?.throwIfAborted() + state.preparedAuto = auto + state.preparedMode = mode +} + +export function codexPermissionRequiresAcquisition( + session: PermissionHost, + mode: AgentChatPermissionChoice +): boolean { + const state = session.permissions + return ( + mode === 'auto' && + !!state && + !state.preparedAuto && + !(state.acquiredMode === 'auto' && state.launchSandboxIntact) + ) +} + +export function codexTurnPermission( + session: PermissionHost, + mode: AgentChatPermissionChoice, + inheritRunning = false +): { approvalPolicy?: string; approvalsReviewer?: string; sandboxPolicy?: CodexSandboxPolicy } { + if (mode === 'own') { + return {} + } + const state = session.permissions + const { approvalPolicy, approvalsReviewer, sandbox } = codexStructuredPermissionPolicy(mode) + const sandboxPolicy: CodexSandboxPolicy | undefined = + sandbox === 'read-only' + ? { type: 'readOnly' } + : 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) { + state.launchSandboxIntact = false + } + return { approvalPolicy, approvalsReviewer, ...(sandboxPolicy ? { sandboxPolicy } : {}) } +} + +/** Preparation is frame-local; only the final host callback decides and synchronously writes. */ +export async function admitCodexTurnPermission( + session: PermissionHost, + admit: StructuredAgentSessionAdmitFreshInput | undefined, + write: ( + permission: ReturnType, + body?: AgentJournalMessageItem + ) => Extract, + signal?: AbortSignal +): Promise { + let prepared = false + const enqueue: Parameters[0] = ( + mode, + inheritRunning, + body + ) => { + if (mode === 'auto' && !prepared) { + return { kind: 'prepare' } + } + if (codexPermissionRequiresAcquisition(session, mode) && !inheritRunning) { + return { kind: 'acquire' } + } + return write(codexTurnPermission(session, mode, inheritRunning), body) + } + for (;;) { + const admission = admit + ? await admit(enqueue) + : enqueue(session.permissions?.acquiredMode ?? 'ask', false) + if (admission.kind === 'prepare') { + await prepareCodexTurnPermission(session, 'auto', signal) + prepared = true + continue + } + if (admission.kind === 'acquire') { + throw new StructuredAgentSessionInputNotAdmitted() + } + return admission.response + } +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 4ff2fbd5d03..04bc0019f2f 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,4 +1,10 @@ -import { pickedSkillsInText } from '../../shared/structured-agent-session-send-mutation' +import { + StructuredAgentSessionInputNotAdmitted, + type StructuredAgentSessionAdmitFreshInput +} from '../native-chat/agent-session-wire/structured-agent-session-input-admission' +import { turnInputFor } from './codex-structured-message-options' +import { reportedCodexPermissionMode } from './codex-structured-thread-open' +import { admitCodexTurnPermission } from './codex-structured-turn-permission' import type { CodexSkillReport } from './codex-structured-skill-report' import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' @@ -6,7 +12,6 @@ import type { AgentJournalMessageItem, AgentJournalTurnJoin } from '../../shared/agent-session-journal-types' -import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error' import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types' @@ -32,10 +37,27 @@ export function isCodexTurnOptionKey(key: string): boolean { return CODEX_TURN_OPTION_KEYS.has(key) } -function checkDispatchAuthority(host: CodexTurnHost, id: string, signal?: AbortSignal): void { +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) - signal.throwIfAborted() + throw new StructuredAgentSessionInputNotAdmitted({ + state: 'rejected', + ...codexDispatchRejection(agentSessionFailureFact('cancelled')) + }) } } @@ -44,6 +66,7 @@ export type CodexTurnHost = { skillReport?: Pick connection: Pick threadId: string + permissions?: CodexSession['permissions'] options: Map appliedTurnOptions?: Readonly> appliedSettings?: CodexSession['appliedSettings'] @@ -56,31 +79,6 @@ export type CodexTurnHost = { turnOpenWaits: Pick } -function turnInputFor( - host: CodexTurnHost, - body: AgentJournalMessageItem -): Record[] { - const input: Record[] = [] - let text = '' - for (const block of body.blocks as NativeChatBlock[]) { - if (block.type === 'text' && block.text.length > 0) { - input.push({ type: 'text', text: block.text }) - text += `${block.text}\n` - } else if (block.type === 'image-ref' && block.path) { - input.push({ type: 'localImage', path: block.path }) - } else if (block.type === 'image-ref' && block.url) { - input.push({ type: 'image', url: block.url }) - } - } - // Unknown skill paths suppress plain $name resolution, so send only loaded files when known. - for (const skill of pickedSkillsInText(text, body.skills ?? [])) { - if (host.skillReport?.hasSkillPath(skill.path) !== false) { - input.push({ type: 'skill', name: skill.name, path: skill.path }) - } - } - return input -} - /** Explicit steering avoids pre-0.148 turn/start replies naming nonexistent turns; overrides wait for a start. */ async function steerCodexTurn( host: CodexTurnHost, @@ -90,11 +88,12 @@ async function steerCodexTurn( body: AgentJournalMessageItem timeoutMs?: number signal?: AbortSignal + isCurrent?: () => boolean onReply?: () => void } ): Promise<{ turnId: string; via: 'steer' } | null> { try { - checkDispatchAuthority(host, input.clientMessageId, input.signal) + checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) const answer = await host.connection.request( 'turn/steer', { @@ -124,6 +123,8 @@ export async function startCodexTurn( timeoutMs?: number turnOptions?: Readonly> signal?: AbortSignal + isCurrent?: () => boolean + admitFreshInput?: StructuredAgentSessionAdmitFreshInput onReply?: () => void } ): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> { @@ -139,6 +140,7 @@ export async function startCodexTurn( runningTurnId = await codexRunningOrOpeningTurn(host, input.signal) } catch (error) { host.dispatchEchoes.disarm(input.clientMessageId) + checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) throw error } let steered = steerable(runningTurnId) ? await steerCodexTurn(host, runningTurnId, input) : null @@ -150,31 +152,60 @@ export async function startCodexTurn( if (steered) { return steered } - checkDispatchAuthority(host, input.clientMessageId, input.signal) const previousApplied = host.appliedTurnOptions const previousOptions = input.turnOptions ? new Map(host.options) : null - if (input.turnOptions) { - host.options.clear() - for (const [key, value] of Object.entries(input.turnOptions)) { - host.options.set(key, value) + let enqueued = false + let discardSettingsAttempt: (() => void) | undefined + const enqueue = ( + permission: Parameters[2]>[0], + body = input.body + ) => { + checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) + if (input.turnOptions) { + host.options.clear() + for (const [key, value] of Object.entries(input.turnOptions)) { + host.options.set(key, value) + } + } + host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options) + const requested = codexTurnRequestOptions(host) + discardSettingsAttempt = host.appliedSettings?.attempt(requested) + enqueued = true + return { + kind: 'sent' as const, + response: host.connection + .request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(host, body), + ...permission, + ...requested + }, + { timeoutMs: input.timeoutMs, onReply: input.onReply } + ) + .then((answer) => { + const applied = reportedCodexPermissionMode({ + ...permission, + sandbox: permission.sandboxPolicy + }) + // Successful fresh-turn policy is applied evidence, not a provider readback. + if (applied && host.reportedOptions) { + host.reportedOptions.reportedPermissionMode = applied + } + return answer + }) } } - host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options) - const requested = codexTurnRequestOptions(host) let answer: unknown - const discardSettingsAttempt = host.appliedSettings?.attempt(requested) try { - answer = await host.connection.request( - 'turn/start', - { - threadId: host.threadId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(host, input.body), - ...requested - }, - { timeoutMs: input.timeoutMs, onReply: input.onReply } - ) + answer = await admitCodexTurnPermission(host, input.admitFreshInput, enqueue, input.signal) } catch (error) { + if (!enqueued) { + host.dispatchEchoes.disarm(input.clientMessageId) + checkDispatchAuthority(host, input.clientMessageId, input.signal, input.isCurrent) + } if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { discardSettingsAttempt?.() } @@ -203,6 +234,8 @@ export async function dispatchCodexTurn( requestedAt?: number turnOptions?: Readonly> signal?: AbortSignal + isCurrent?: () => boolean + admitFreshInput?: StructuredAgentSessionAdmitFreshInput }, timeoutMs: number | undefined ): Promise { @@ -215,6 +248,9 @@ export async function dispatchCodexTurn( try { answer = await startCodexTurn(session, { ...input, timeoutMs, onReply }) } catch (error) { + if (error instanceof StructuredAgentSessionInputNotAdmitted) { + throw error + } if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { // Codex answered and declined, so no echo for this write can arrive. session.dispatchEchoes.disarm(input.clientMessageId) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 363a1808a11..427ba1cd078 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -75,6 +75,7 @@ export function applyJournalDispatchRow( }) } +/** A rebind replaces coverage, including an empty set, before the new child receives input. */ function foldStoppedHistoryCarried( state: JournalReducerState, row: Extract @@ -84,6 +85,8 @@ function foldStoppedHistoryCarried( : [] if (carried.length > 0) { state.stoppedHistoryCarried.set(row.clientMessageId, carried) + } else { + state.stoppedHistoryCarried.delete(row.clientMessageId) } } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index ebebff44318..aea71686dd0 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -65,7 +65,7 @@ export async function holdUnsentSends( fence: number hold: UnsentSendHold /** Settle handed-over sends instead when failed initialization proves none ran. */ - unrun?: true + unrun?: true | ((submission: AgentJournalSubmission) => boolean) } ): Promise { const { hold } = input @@ -73,7 +73,7 @@ export async function holdUnsentSends( .submissions() .filter((entry) => input.unrun - ? isUnansweredHandedOverSubmission(entry) + ? isUnansweredHandedOverSubmission(entry) && (input.unrun === true || input.unrun(entry)) : isQueuedAgentJournalSubmission(entry) && (hold.cause === 'hostRestarted' ? journal.wroteBeforeOpen(entry.acceptedSequence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index 44cd322d073..5e398866864 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,7 +1,7 @@ export class StructuredAgentSessionAcquireAborts { private readonly inFlight = new Map< string, - Set<{ controller: AbortController; handover: boolean; goal: boolean; opening: boolean }> + Set<{ controller: AbortController; handover: boolean; goal: boolean; bound?: () => boolean }> >() /** Starts admitted after quit must begin aborted. */ private quitReason: Error | null = null @@ -11,7 +11,7 @@ export class StructuredAgentSessionAcquireAborts { sessionId: string, handover = false, goal = false, - opening = false + bound?: () => boolean ): { signal: AbortSignal; abort: () => void; end: () => void } { const controller = new AbortController() if (this.quitReason) { @@ -19,8 +19,13 @@ export class StructuredAgentSessionAcquireAborts { } const waits = this.inFlight.get(sessionId) ?? - new Set<{ controller: AbortController; handover: boolean; goal: boolean; opening: boolean }>() - const active = { controller, handover, goal, opening } + new Set<{ + controller: AbortController + handover: boolean + goal: boolean + bound?: () => boolean + }>() + const active = { controller, handover, goal, bound } waits.add(active) this.inFlight.set(sessionId, waits) return { @@ -56,10 +61,11 @@ export class StructuredAgentSessionAcquireAborts { return { reached, handedOver } } - abortOpen(sessionId: string): void { + /** Called synchronously while the exact ending child is still indexed. */ + abortBound(sessionId: string): void { for (const wait of this.inFlight.get(sessionId) ?? []) { - if (wait.opening) { - wait.controller.abort(new Error('provider exited while opening')) + if (wait.bound?.()) { + wait.controller.abort(new Error('bound provider child exited')) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 252cc914333..e801d146bc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,4 +1,5 @@ import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -50,6 +51,12 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) + permissionModeRequiresAcquisition = ( + sessionId: string, + mode: AgentChatPermissionChoice + ): boolean | undefined => + this.liveOwnerOrNull(sessionId)?.permissionModeRequiresAcquisition?.(sessionId, mode) + /** Session support may narrow the owner’s declared rewind, never widen it. */ rewindSupport: NonNullable = ( sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index fabca1ddb8b..672f647d520 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,4 +1,6 @@ +import type { StructuredAgentSessionAdmitFreshInput } from './structured-agent-session-input-admission' import type { AiVaultConversationCopy } from '../../../shared/ai-vault-resume-preparation' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { AgentSessionRewindFork, AgentSessionRewindReason, @@ -174,6 +176,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & releaseAcquisition?(input: { sessionId: string }): Promise hasOwnedAcquisition?(sessionId: string): boolean stoppedAcquisition?(sessionId: string): StructuredAgentSessionAcquisitionReceipt | undefined + permissionModeRequiresAcquisition?( + sessionId: string, + mode: AgentChatPermissionChoice + ): boolean | undefined dispatch(input: { sessionId: string clientMessageId: string @@ -186,6 +192,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Preserve submission time as the opened turn’s requestedAt. */ requestedAt?: number beforeDispatch?: () => Promise + /** Whether the host still owns this exact child after asynchronous preparation. */ + isCurrent?: () => boolean + admitFreshInput?: StructuredAgentSessionAdmitFreshInput /** Mark preparation before choices mutate the provider. */ onInputPreparation?: () => void /** Checkpoint between choice application and execution-evidence capture. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 59ccc977485..1d4899d3f37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -27,7 +27,7 @@ import { settleStaleStructuredAgentSessionState } from './structured-agent-sessi import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import { structuredAgentSessionAttachPermission } from './structured-agent-session-launch-permission' import { isStructuredAgentSessionStartupExpired, type StructuredAgentSessionStartupAttempt @@ -240,9 +240,7 @@ async function runAttachUnderAbort( return startupAttempts.track(sessionId, startup) }, ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), - launchPermissionMode: async (record) => - (await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ?? - undefined, + launchPermissionMode: structuredAgentSessionAttachPermission(context, options, lock), assertNoTerminalWriter: async (record) => { await lock.wait(Promise.resolve(context.deps.assertNoTerminalWriter?.(record))) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 4c9d7ca8adc..791a69a7961 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -69,7 +69,7 @@ export type StructuredAgentSessionChildExitContext< startupAttempts?: Pick /** Retain exits between background-start spawn and publication. */ startFlights?: Pick - abortOpen?: (sessionId: string) => void + abortBound?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger @@ -141,8 +141,8 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< const observedAt = observation?.observedAt ?? exit.observedAt ?? context.now() // Host phase classifies startup failure even if adapters omit the flag. const exitedDuringStartup = child.phase === 'starting' - if (child.phase === 'starting') { - context.abortOpen?.(sessionId) + if (child.phase === 'starting' || !expected) { + context.abortBound?.(sessionId) } // Personal Stop cancels startup; host Stop fails it; preserve the close initiator. const startClose = expected && exitedDuringStartup ? close?.cause : undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts index 02783384bcc..8a3a8373898 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -1,3 +1,4 @@ +import { structuredAgentSessionHandingOver } from './structured-agent-session-handing-over' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' @@ -356,3 +357,70 @@ it('keeps the note unconfirmed when the turn completes', async () => { expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) }) }) + +it.each([false, true])( + 'keeps only positively unwritten Claude input on Close (written: %s)', + async (written) => { + const connection = claude.connections[0]! + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + const control = vi.spyOn(connection, 'setModel').mockImplementation(async () => { + entered.resolve() + await held.promise + }) + const dispatch = vi.spyOn(adapter, 'dispatch') + const fields = { body: hostTestMessage('recover exact input'), options: { model: 'opus' } } + const sending = await host.send(CALLER, { + envelope: envelope('agentSession.send', fields), + userSend: true, + ...fields + }) + if (!sending.ok) { + throw new Error(JSON.stringify(sending.refusal)) + } + const id = sending.value.clientMessageId + const journal = host['sessions'].get(SESSION)!.journal + const waiting = host.waitForSendSettlement(SESSION, id) + try { + await entered.promise + expect(connection.sent).toHaveLength(0) + const original = structuredAgentSessionHandingOver(journal).get(id)?.input + if (written) { + held.resolve() + await eventually(() => expect(connection.sent).toHaveLength(1)) + } + await host.close(SESSION, 'user-close') + held.resolve() + await Promise.allSettled(dispatch.mock.results.map((result) => result.value)) + await original?.finalization.completion + await host.flushStreamedEvents(SESSION) + await journal['queue'].drain() + const row = journal.submission(id) + const cards = journal.queuedMessages.list() + expect(original).toBeDefined() + expect(structuredAgentSessionHandingOver(journal).has(id)).toBe(false) + expect(connection.sent).toHaveLength(written ? 1 : 0) + expect((await waiting)?.value).toEqual({ clientMessageId: id, submission: row }) + expect(row).toMatchObject({ + clientMessageId: id, + source: { kind: 'user' }, + options: fields.options + }) + expect(cards.map((card) => card.messageId)).toEqual(written ? [] : [id]) + if (!written) { + expect(row).toMatchObject({ + dispatchState: 'rejected', + keptAsQueuedMessageId: id, + rejection: { kind: 'chatClosed' } + }) + expect(cards[0]).toMatchObject({ body: fields.body, options: fields.options, position: 0 }) + } else { + expect(row).not.toHaveProperty('keptAsQueuedMessageId') + } + } finally { + held.resolve() + control.mockRestore() + dispatch.mockRestore() + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts new file mode 100644 index 00000000000..a674108c974 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-admission.spec.ts @@ -0,0 +1,356 @@ +import * as autoSandbox from '../../codex/codex-structured-auto-sandbox' +import { expect, it, vi } from 'vitest' +import { codexInputFinalizationFixture } from './structured-agent-session-codex-input-host.test' +import { + continuingStructuredAgentSessionInput, + finalizingStructuredAgentSessionInput +} from './structured-agent-session-handing-over' +import { + CODEX_INPUT_FINALIZATION_ADMISSION_CASES, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +it.each(CODEX_INPUT_FINALIZATION_ADMISSION_CASES)( + 'keeps input unwritten across %s at final admission with %i earlier cards', + async (boundary, population) => { + const { host, adapter, codex, config, permissionSend, stop, ...fixture } = + codexInputFinalizationFixture() + const { sessions, conversationDelivery, serialize } = host.collaboratorsForTests() + const journal = sessions.get(SESSION)!.journal + const closing = boundary.includes('close') || boundary.endsWith('sleep') + const earlier = Array.from({ length: population }, (_, index) => `perf-kept-${index}`) + await serialize(SESSION, async () => { + for (const messageId of earlier) { + await journal.appendSubmission({ + clientMessageId: messageId, + payloadFingerprint: messageId, + body: hostTestMessage(messageId), + source: { kind: 'user' }, + origin: 'client', + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: messageId, + state: 'rejected', + fence: 1, + keptAsQueuedMessageId: messageId, + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' + }) + }) + await journal.queuedMessages.insert({ messageId, body: hostTestMessage(messageId) }) + } + if (population > 0) { + await journal.markQueueReopen(1) + } + }) + await host.flushStreamedEvents(SESSION) + const measured = population > 0 ? fixture.measureInputWork('close', population) : undefined + let later = '' + const paused = Promise.withResolvers() + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + const deps = conversationDelivery.loop['deps'] + let signal: AbortSignal | undefined + let frameEnded = false + let configEntered = false + const dispatch = adapter.dispatch.bind(adapter) + vi.spyOn(adapter, 'dispatch').mockImplementation(async (input) => { + signal = input.signal + codex.routes['config/read'] = () => { + configEntered = true + return paused.promise + } + try { + return await dispatch(input) + } finally { + frameEnded = true + } + }) + let id = '' + let blocking = Promise.resolve() + let stopping = Promise.resolve() + try { + id = await permissionSend('do not write after cancellation', { personality: 'friendly' }) + await vi.waitFor(() => expect(configEntered && !!signal).toBe(true)) + if (boundary === 'pending-close-cards') { + later = await permissionSend('later queued B', { personality: 'serious' }) + await journal.queuedMessages.insert({ + messageId: 'C', + body: hostTestMessage('waiting C'), + options: { model: 'model-C' } + }) + } + const child = sessions.get(SESSION)!.child! + const settled = host.waitForSendSettlement(SESSION, id) + const answeredBeforeRelease = vi.fn() + void settled.then(answeredBeforeRelease) + let rejectedAtFence: number | undefined + const resolve = journal.resolveDispatch.bind(journal) + const ordered = deps.serialize + if (boundary.startsWith('queue-')) { + blocking = serialize(SESSION, () => { + entered.resolve() + return held.promise + }) + await entered.promise + const admission = Promise.withResolvers() + vi.spyOn(deps, 'serialize').mockImplementation((sessionId, task) => { + admission.resolve() + return ordered(sessionId, task) + }) + paused.resolve(config) + await admission.promise + } else { + vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { + if (args[0].state === 'pending') { + entered.resolve() + await held.promise + } + if (args[0].state === 'rejected') { + rejectedAtFence = fixture.conversationFence() + } + return resolve(...args) + }) + paused.resolve(config) + await entered.promise + } + stopping = boundary.endsWith('exit') + ? Promise.resolve( + codex.connections[0]?.handlers.onExit?.(new Error('Codex exited before input'), { + expected: false + }) + ) + : boundary === 'pending-close-then-quit' + ? host.close(SESSION, 'user-close') + : boundary.endsWith('quit') || boundary === 'pending-quit-then-close' + ? host.flushAllStreamedEvents() + : boundary.endsWith('sleep') + ? host.sleepWorkspace('workspace-1').then(() => {}) + : closing + ? host.close(SESSION, 'user-close') + : stop().then(() => {}) + if (!boundary.endsWith('exit')) { + await vi.waitFor(() => expect(signal?.aborted && frameEnded).toBe(true)) + } + if (boundary.includes('-then-')) { + const second = boundary.endsWith('quit') + ? host.flushAllStreamedEvents() + : host.close(SESSION, 'user-close') + stopping = Promise.all([stopping, second]).then(() => {}) + } + expect(fixture.starts()).toHaveLength(0) + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(false) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + if (boundary.endsWith('stop')) { + expect(fixture.submission((await settled)?.value)).toMatchObject({ + dispatchState: 'rejected', + fence: 1, + rejection: { kind: 'cancelled' } + }) + expect(answeredBeforeRelease).toHaveBeenCalledOnce() + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + } else { + expect(answeredBeforeRelease).not.toHaveBeenCalled() + } + held.resolve() + await blocking + await stopping + await fixture.waitForInputFinalization(journal, id) + await conversationDelivery.loop['runs'].get(SESSION) + await journal['queue'].drain() + const answered = fixture.submission((await settled)?.value) + await vi.waitFor(() => expect(journal.submission(id)?.dispatchState).toBe('rejected')) + expect(answered).toMatchObject({ + dispatchState: 'rejected', + fence: + boundary === 'pending-quit-then-close' + ? child.fence + : closing + ? fixture.conversationFence() + : 1 + }) + expect(answered).toEqual(journal.submission(id)) + expect(answered?.reason).toMatch( + /before (this message was sent|the agent started it)|provider_cancelled_before_start/ + ) + expect(answered?.rejection?.kind).toBe( + closing + ? 'chatClosed' + : boundary.endsWith('quit') + ? 'hostRestarted' + : boundary.endsWith('exit') + ? 'providerExited' + : 'cancelled' + ) + const cards = journal.queuedMessages.list() + if (closing || boundary.endsWith('quit')) { + expect(answered?.keptAsQueuedMessageId).toBe(id) + expect(journal.submission(id)?.source).toEqual({ kind: 'user' }) + expect(cards.find((card) => card.messageId === id)).toMatchObject({ + body: { blocks: [{ type: 'text', text: 'do not write after cancellation' }] }, + options: { personality: 'friendly' }, + ...(later ? {} : { position: 0 }) + }) + } + if (later) { + expect(cards.map((card) => card.messageId)).toEqual([...earlier, later, id, 'C']) + expect(cards[earlier.length]).toMatchObject({ + body: hostTestMessage('later queued B'), + options: { personality: 'serious' } + }) + expect(journal.submission(later)?.keptAsQueuedMessageId).toBe(later) + for (const [index, messageId] of earlier.entries()) { + expect(cards[index]).toMatchObject({ messageId, body: hostTestMessage(messageId) }) + expect(journal.submission(messageId)?.keptAsQueuedMessageId).toBe(messageId) + } + expect(cards[earlier.length + 2]).toMatchObject({ + body: hostTestMessage('waiting C'), + options: { model: 'model-C' } + }) + } + expect(fixture.starts()).toHaveLength(0) + await fixture.waitForInputFinalization(journal, id) + if (boundary.includes('-then-')) { + expect(answered?.fence).toBe(rejectedAtFence) + expect(fixture.conversationFence()).toBe(child.fence + 1) + expect(cards.map((card) => card.messageId)).toEqual([id]) + expect(child.close).toMatchObject({ cause: 'user-close' }) + expect(child.close).not.toHaveProperty('quit') + expect(codex.connections[0]?.closed).toBe(true) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false) + } + } finally { + paused.resolve(config) + held.resolve() + await blocking + await stopping + await fixture.waitForInputFinalization(journal, id) + await measured?.() + } + } +) + +it.each(['stop', 'exit', 'exit-after-close-record'] as const)( + 'unwinds actual Auto preparation on %s without a late write', + async (ending) => { + const { host, adapter, codex, config, pick, permissionSend, stop, starts } = + codexInputFinalizationFixture() + const exiting = ending !== 'stop' + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + await pick('auto') + const paused = Promise.withResolvers() + let signal: AbortSignal | undefined + const lateReads: Promise[] = [] + const readAuto = autoSandbox.readCodexAutoSandbox + vi.spyOn(autoSandbox, 'readCodexAutoSandbox').mockImplementation((...args) => { + const task = readAuto(...args) + lateReads.push(task) + return task + }) + let preparing = false + let frameEnded = false + const dispatch = adapter.dispatch.bind(adapter) + vi.spyOn(adapter, 'dispatch').mockImplementation(async (input) => { + signal = input.signal + codex.routes['config/read'] = (params) => { + if (!params?.cwd) { + return config + } + preparing = true + return paused.promise + } + try { + return await dispatch(input) + } finally { + frameEnded = true + } + }) + const id = await permissionSend('keep exact Auto') + const journal = host['sessions'].get(SESSION)!.journal + try { + await vi.waitFor(() => expect(preparing).toBe(true)) + const session = adapter['sessions'].get(SESSION)! + const permissions = session.permissions! + const before = { ...permissions } + const options = [...session.options] + const waiting = host.waitForSendSettlement(SESSION, id) + let answeredEarly = false + void waiting.then(() => { + answeredEarly = true + }) + const resolve = journal.resolveDispatch.bind(journal) + const written = vi + .spyOn(journal, 'resolveDispatch') + .mockImplementation((resolution, ...rest) => { + if (resolution.state === 'rejected') { + expect(answeredEarly).toBe(false) + expect(resolution).toMatchObject({ + clientMessageId: id, + fence: 1, + rejection: { kind: exiting ? 'providerExited' : 'cancelled' } + }) + expect(resolution.rejection).toEqual( + exiting ? { kind: 'providerExited', neverReachedAgent: true } : { kind: 'cancelled' } + ) + expect(resolution.reason).toMatch(/before/) + if (exiting) { + expect(host['sessions'].get(SESSION)?.lastEndedChild?.failure?.detail).toEqual({ + text: 'Codex exited', + audience: 'log' + }) + } + } + return resolve(resolution, ...rest) + }) + expect(signal?.aborted).toBe(false) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + if (ending === 'exit-after-close-record') { + const { sessions, serialize, lifetime } = host.collaboratorsForTests() + const child = sessions.get(SESSION)!.child! + vi.spyOn(adapter, 'disposeSession').mockResolvedValue(false) + await expect( + serialize(SESSION, () => lifetime.stopAgent(SESSION, { cause: 'evict' })) + ).rejects.toThrow() + expect(sessions.get(SESSION)?.child).toBe(child) + expect(child.close).toMatchObject({ cause: 'evict' }) + expect(signal?.aborted).toBe(false) + } + if (ending === 'stop') { + await stop() + } else { + codex.connections[0]?.handlers.onExit?.( + Object.assign(new Error('Codex exited'), { + providerDiagnostic: { text: 'Codex exited', audience: 'log' } + }), + { expected: false } + ) + } + await vi.waitFor(() => expect(signal?.aborted && frameEnded).toBe(true)) + const answered = codexInputFinalizationFixture().submission((await waiting)?.value) + expect(answered).toMatchObject({ + dispatchState: 'rejected', + fence: 1, + rejection: { kind: exiting ? 'providerExited' : 'cancelled' } + }) + expect(answered).toEqual(journal.submission(id)) + expect(starts()).toHaveLength(0) + paused.resolve(config) + await Promise.allSettled(lateReads) + await codexInputFinalizationFixture().waitForInputFinalization(journal, id) + expect(session.permissions).toBe(permissions) + expect(permissions).toEqual(before) + expect([...session.options]).toEqual(options) + expect(starts()).toHaveLength(0) + const writes = await Promise.allSettled(written.mock.results.map((result) => result.value)) + expect(writes.filter((result) => result.status === 'rejected')).toEqual([]) + } finally { + paused.resolve(config) + await Promise.allSettled(lateReads) + await codexInputFinalizationFixture().waitForInputFinalization(journal, id) + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts new file mode 100644 index 00000000000..1179ecbb934 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-finalization.spec.ts @@ -0,0 +1,760 @@ +import type { codexInputFinalizationFixture } from './structured-agent-session-codex-input-host.test' +import { expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE as turnScope } from '../../../shared/agent-session-journal-types' +import { + continuingStructuredAgentSessionInput, + finalizingStructuredAgentSessionInput, + structuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error' +import { CodexAppServerTimeoutError } from '../../codex/codex-app-server-session' +import { + CODEX_INPUT_FINALIZATION_OWNER_OUTCOMES, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import * as lifetime from './structured-agent-session-host-lifetime' +import { claimStructuredAgentSessionAgent } from './structured-agent-session-agent-start' +import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' + +export function registerCodexInputFinalization(current: typeof codexInputFinalizationFixture) { + it.each([ + 'stop', + 'exit', + 'crash', + 'refusal', + 'unproven-exit', + 'lost-reply', + 'lost-steer', + 'post-write-stop' + ] as const)('bounds %s without replaying possibly written input', async (failure) => { + const { + host, + adapter, + codex, + config, + permissionSend, + pick, + stop, + row, + starts, + turns, + began, + assertInputRecoveredAfterSnapshot + } = current() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const uncertain = failure.startsWith('lost-') + const written = uncertain || failure === 'post-write-stop' + const { sessions, runtimeState } = host.collaboratorsForTests() + const journal = sessions.get(SESSION)!.journal + const persisted = vi.spyOn(journal, 'resolveDispatch') + if (failure === 'lost-steer') { + await began(await permissionSend('running')) + } + const paused = Promise.withResolvers() + codex.routes['config/read'] = () => paused.promise + const id = await permissionSend('preserve me', { personality: 'friendly' }) + await vi.waitFor(() => + expect(codex.connections[0]?.calls.some((r) => r.method === 'config/read')).toBe(true) + ) + await pick('auto') + if (failure === 'refusal') { + codex.routes['thread/start'] = codex.routes['thread/resume'] = () => { + throw new AgentSessionAcquisitionRefusal('missing Codex', 'cliMissing') + } + } + if (failure === 'lost-reply' || failure === 'lost-steer') { + codex.routes[failure === 'lost-steer' ? 'turn/steer' : 'turn/start'] = () => { + throw new CodexAppServerTimeoutError('lost turn reply') + } + } + if (failure === 'unproven-exit') { + vi.spyOn(adapter, 'disposeSession').mockResolvedValue(false) + } + const lost = Promise.withResolvers() + const responseObserved = lost.promise.catch(() => {}) + if (failure === 'post-write-stop') { + codex.routes['turn/start'] = () => lost.promise + } + paused.resolve(config) + if (failure === 'post-write-stop') { + await vi.waitFor(() => expect(starts()).toHaveLength(1)) + const session = host.collaboratorsForTests().sessions.get(SESSION)! + const child = session.child! + const provider = adapter['sessions'].get(SESSION)! + expect(turns.turnId).toBeNull() + const stopped = stop() + lost.reject(new CodexAppServerTimeoutError('lost reply after Stop')) + expect(await stopped).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(session.child).toBeNull()) + expect(session.lastEndedChild).toMatchObject({ + fence: child.fence, + generation: child.generation, + cause: 'user-stop', + rootGone: true + }) + expect(provider.ended).toBe(true) + expect(provider.connection.closed).toBe(true) + expect(await row(id)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'cancelled' }, + recovered: true + }) + } + if (failure === 'stop' || failure === 'exit' || failure === 'crash') { + const compiling = Promise.withResolvers() + const open = codex.routes['thread/start']! + codex.routes['thread/start'] = codex.routes['thread/resume'] = () => compiling.promise + 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) + } + if (failure === 'exit') { + const failed = sessions.get(SESSION)!.child! + const owner = structuredAgentSessionHandingOver(journal).get(id)?.input?.owner + const flight = runtimeState.startFlights.get(SESSION)! + const originalSignal = structuredAgentSessionHandingOver(journal).get(id)?.input?.signal + expect(originalSignal?.aborted).toBe(false) + expect(owner).toEqual(expect.any(Function)) + expect(flight.signal?.aborted).toBe(false) + let retries = 0 + codex.routes['thread/start'] = codex.routes['thread/resume'] = () => { + retries += 1 + expect(originalSignal?.aborted).toBe(false) + expect(runtimeState.startFlights.get(SESSION)?.signal).toBe(flight.signal) + expect(structuredAgentSessionHandingOver(journal).get(id)?.input?.owner).toBe(owner) + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(journal.submission(id)).toMatchObject({ dispatchState: 'pending', fence: 1 }) + expect(starts()).toHaveLength(0) + throw new CodexAppServerRequestError( + 'thread/resume', + -32000, + 'startup failed', + 'startup failed' + ) + } + codex.connections[1]?.handlers.onExit?.(new Error('Codex died before ready'), { + expected: false + }) + await vi.waitFor(() => + expect(sessions.get(SESSION)?.lastEndedChild).toMatchObject({ + fence: failed.fence, + generation: failed.generation, + rootGone: true + }) + ) + expect(flight.signal?.aborted).toBe(false) + compiling.resolve(open({})) + const outcome = await flight.outcome + expect(outcome).toMatchObject({ ok: false }) + expect(outcome).not.toHaveProperty('aborted') + expect(retries).toBe(4) + expect(originalSignal?.aborted).toBe(false) + expect(runtimeState.startFlights.get(SESSION)).toBeUndefined() + } else { + expect(await stop()).toMatchObject({ ok: true }) + compiling.resolve(open({})) + } + } + await vi.waitFor(() => expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false)) + const writes = await Promise.allSettled(persisted.mock.results.map((result) => result.value)) + expect(writes.filter((result) => result.status === 'rejected')).toEqual([]) + await vi.waitFor(async () => + expect((await row(id))?.dispatchState).toBe(uncertain ? 'unknown' : 'rejected') + ) + if (failure === 'unproven-exit') { + expect((await row(id))?.rejection).toMatchObject({ + kind: 'restartFailed', + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'previousExitUnverifiable' } + } + }) + } + if (failure === 'exit') { + expect(journal.submission(id)).toMatchObject({ + clientMessageId: id, + rejection: { kind: 'startFailed' }, + options: { personality: 'friendly' } + }) + expect(journal.itemBody(agentJournalSubmissionKey(id))).toEqual( + hostTestMessage('preserve me') + ) + expect(current().submission((await host.waitForSendSettlement(SESSION, id))?.value)).toEqual( + journal.submission(id) + ) + } + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(false) + expect(starts()).toHaveLength(written ? 1 : 0) + lost.resolve({}) + await responseObserved + expect(codex.connections).toHaveLength( + failure === 'exit' ? 6 : failure === 'lost-steer' || failure === 'unproven-exit' ? 1 : 2 + ) + }) + + it.each(CODEX_INPUT_FINALIZATION_OWNER_OUTCOMES)( + 'finishes its exact owner with %s resolution', + async (result) => { + const fixture = current() + const { host, adapter, codex, turns, permissionSend, starts } = fixture + const { sessions, serialize, conversationDelivery, runtimeState } = + host.collaboratorsForTests() + const old = sessions.get(SESSION)! + const journal = old.journal + const initialChild = old.child! + const closed = vi.spyOn(journal, 'close') + let currentClose = closed + let currentJournal = journal + const deps = conversationDelivery.loop['deps'] + const ordered = deps.serialize + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + const closeCompletion = result.startsWith('new-') || result === 'safe-close' + const closing = closeCompletion || result.startsWith('close-') + const persistence = ['normal', 'fallback', 'failed', 'close-normal', 'close-failed'].includes( + result + ) + const failsBoth = result === 'failed' || result === 'close-failed' + let signal: AbortSignal | undefined + const beginAbort = runtimeState.acquireAborts.begin.bind(runtimeState.acquireAborts) + vi.spyOn(runtimeState.acquireAborts, 'begin').mockImplementation((...args) => { + const attempt = beginAbort(...args) + if (args[1]) { + signal = attempt.signal + } + return attempt + }) + const dispatched = vi.spyOn(adapter, 'dispatch') + const closeDecision = vi.fn<(id: string) => Promise>() + const delayed = vi.spyOn(deps, 'serialize').mockImplementation(async (sessionId, task) => { + const value = await ordered(sessionId, task) + if (result === 'null' && value && typeof value === 'object' && 'run' in value) { + entered.resolve() + await held.promise + } + return value + }) + let retirement = Promise.resolve() + const begin = lifetime.beginFinalizedStructuredAgentSessionClose + const helper = vi.spyOn(lifetime, 'beginFinalizedStructuredAgentSessionClose') + helper.mockImplementation((context, ...args) => { + if (!closeCompletion) { + return begin(context, ...args) + } + closeDecision.mockImplementation(context.closeConversation) + return begin( + { + ...context, + closeConversation: closeDecision, + serialize: (id, task) => { + entered.resolve() + retirement = held.promise.then(() => context.serialize(id, task)) + return retirement + } + }, + ...args + ) + }) + const response = Promise.withResolvers() + codex.routes['turn/start'] = () => response.promise + if (failsBoth) { + await serialize(SESSION, async () => { + expect(journal.submissions()).toEqual([]) + for (const fence of [0, 1]) { + const clientMessageId = `unrelated-${fence}` + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: clientMessageId, + body: hostTestMessage('unrelated pending input'), + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ clientMessageId, state: 'pending', fence, turnScope }) + } + }) + } + const id = await permissionSend('outcome owed after exit') + const waiting = host.waitForSendSettlement(SESSION, id) + let answered = false + void waiting.then(() => { + answered = true + }) + const settle = deps.settleLeftovers! + const leftovers = vi.spyOn(deps, 'settleLeftovers').mockImplementation((sessionId, owner) => { + expect(sessionId).toBe(SESSION) + expect(owner).toEqual(expect.any(Function)) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(finalizingStructuredAgentSessionInput(journal, id, owner)).toBe(false) + expect(finalizingStructuredAgentSessionInput(journal, id, () => {})).toBe(true) + if (result !== 'terminal') { + expect(answered).toBe(false) + } + return settle(sessionId, owner) + }) + const recover = journal.appendPlannedLifecycleBatch.bind(journal) + const recovered = vi.spyOn(journal, 'appendPlannedLifecycleBatch') + recovered.mockImplementation((batch) => { + expect(batch.fence).toBe(current().conversationFence()) + return recover(batch) + }) + const resolve = journal.resolveDispatch.bind(journal) + const wake = conversationDelivery.loop.wake.bind(conversationDelivery.loop) + const deferredWake = vi.spyOn(conversationDelivery.loop, 'wake') + let stopping = Promise.resolve() + let closeStarted = false + let writes = 0 + function assertStoppedHandle() { + expect(sessions.get(SESSION)).toBe(old) + expect(closed).not.toHaveBeenCalled() + expect(old.child).toBeNull() + expect(host['deps'].store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + } + try { + if (result === 'null') { + await entered.promise + expect(old.child?.phase).toBe('ready') + expect(signal?.aborted).toBe(false) + } else { + await vi.waitFor(() => expect(starts()).toHaveLength(1)) + } + if (result === 'terminal') { + turns.routes['turn/start']() + turns.start() + turns.echo(id) + await vi.waitFor(async () => + expect(journal.submission(id)).toMatchObject({ dispatchState: 'accepted' }) + ) + } + const cutoff = journal.cursor().sequence + if (closing) { + closeStarted = true + stopping = host.close(SESSION, 'user-close') + await stopping + } else if (result !== 'live') { + if (result === 'null') { + expect(await adapter.closeSession(SESSION)).toBe(true) + } else { + codex.connections[0]!.handlers.onExit?.(new Error('Codex exited after write'), { + expected: false + }) + } + await serialize(SESSION, async () => {}) + } + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(closed).not.toHaveBeenCalled() + if (result !== 'terminal') { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'pending' }) + expect(host.currentWork(SESSION)?.owesSend(journal.submission(id)!)).toBe(true) + } + if (result !== 'live') { + assertStoppedHandle() + } + if (failsBoth) { + const fence = recovered.mock.calls.at(-1)![0].fence + for (const previous of [0, 1]) { + expect(journal.submission(`unrelated-${previous}`)).toMatchObject({ + clientMessageId: `unrelated-${previous}`, + payloadFingerprint: `unrelated-${previous}`, + dispatchState: 'unknown', + recovered: true, + fence + }) + } + } + if (result === 'null') { + expect(signal?.aborted).toBe(false) + expect(dispatched).not.toHaveBeenCalled() + expect(old.lastEndedChild).toMatchObject({ + fence: initialChild.fence, + generation: initialChild.generation + }) + } + if (persistence) { + vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { + if (args[0].state === 'unknown') { + writes += 1 + if (writes === 1) { + entered.resolve() + await held.promise + } + if (failsBoth || (result === 'fallback' && writes === 1)) { + throw new Error('controlled result persistence failure') + } + } + return resolve(...args) + }) + response.reject(new CodexAppServerTimeoutError('lost written reply')) + await entered.promise + assertStoppedHandle() + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(journal.submission(id)?.dispatchState).toBe('pending') + expect(answered).toBe(false) + if (closing) { + expect(codex.connections[0]?.closeCount).toBeGreaterThan(0) + } + } else { + response.resolve({}) + } + if (closeCompletion) { + await entered.promise + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false) + const ended = old.lastEndedChild + expect(leftovers).toHaveBeenCalledOnce() + if (result === 'new-session') { + const opened = await openStructuredAgentSessionConversationJournal( + host['deps'], + host['deps'].store.getRecord(SESSION)! + ) + sessions.set(SESSION, opened.session) + currentJournal = opened.session.journal + currentClose = vi.spyOn(opened.session.journal, 'close') + expect(opened.session).not.toBe(old) + expect(currentJournal).not.toBe(journal) + expect(sessions.get(SESSION)).toBe(opened.session) + expect(opened.session.child).toBeNull() + expect(currentJournal.pendingSubmissions()).toEqual([]) + } else if (result === 'new-send') { + deferredWake.mockResolvedValue() + const newer = await permissionSend('new durably accepted input') + expect(journal.submission(newer)?.acceptedSequence).toBeGreaterThan(cutoff) + expect(old.child).toBeNull() + } else if (result !== 'safe-close') { + const claim = await serialize(SESSION, async () => + claimStructuredAgentSessionAgent(host['attachContext'](), SESSION) + ) + expect(await claim.outcome).toMatchObject({ ok: true }) + const child = old.child! + expect(child.phase).toBe('ready') + expect(child.fence).toBe(current().conversationFence()) + expect(child.generation).not.toBe(ended?.generation) + if (result === 'new-ended-child') { + expect(await adapter.closeSession(SESSION)).toBe(true) + await serialize(SESSION, async () => {}) + expect(old.child).toBeNull() + expect(old.lastEndedChild).not.toBe(ended) + } + } + if (result !== 'new-ended-child' && result !== 'new-session') { + expect(old.lastEndedChild).toBe(ended) + } + if (result !== 'new-send') { + expect(journal.submissions().some((s) => (s.acceptedSequence ?? 0) > cutoff)).toBe( + false + ) + } + expect(closed).not.toHaveBeenCalled() + expect(currentClose).not.toHaveBeenCalled() + } + held.resolve() + await retirement + await fixture.waitForInputFinalization(journal, id) + expect(starts()).toHaveLength(result === 'null' ? 0 : 1) + expect(deps.deliveryOrder?.hasActiveSend(SESSION)).toBe(false) + expect(leftovers).toHaveBeenCalledTimes( + ['null', 'admitted', 'terminal'].includes(result) || closeCompletion ? 1 : 0 + ) + if (result === 'live') { + expect(journal.submission(id)?.dispatchState).toBe('pending') + expect(leftovers).not.toHaveBeenCalled() + } else if (failsBoth) { + expect(writes).toBe(2) + expect(journal.submission(id)?.dispatchState).toBe('pending') + assertStoppedHandle() + expect( + journal.submissions().filter((s) => s.clientMessageId.startsWith('unrelated-')) + ).toHaveLength(2) + vi.mocked(journal.resolveDispatch).mockRestore() + await fixture.assertInputRecoveredAfterSnapshot(id) + } else { + expect(fixture.submission((await waiting)?.value)?.dispatchState).toBe( + result === 'terminal' ? 'accepted' : 'unknown' + ) + expect(fixture.submission((await waiting)?.value)).toEqual(journal.submission(id)) + if (persistence) { + expect(writes).toBe(result === 'fallback' ? 2 : 1) + } + } + if (closeCompletion) { + expect(closeDecision).toHaveBeenCalledTimes(result === 'safe-close' ? 1 : 0) + if (result === 'safe-close') { + expect(sessions.has(SESSION)).toBe(false) + expect(closed).toHaveBeenCalledOnce() + } else { + expect(sessions.get(SESSION)?.journal).toBe(currentJournal) + expect(currentClose).not.toHaveBeenCalled() + } + } + } finally { + response.resolve({}) + held.resolve() + await stopping + await fixture.waitForInputFinalization(journal, id) + if (closeCompletion && closeStarted) { + await entered.promise + } + await retirement + delayed.mockRestore() + helper.mockRestore() + deferredWake.mockRestore() + if (result === 'new-send') { + await wake(SESSION) + } + if (result === 'new-session') { + await journal.close() + } + } + } + ) + + it.each([ + 'retire', + 'acquire', + 'readiness', + 'rebind', + 'starting-exit', + 'close-acquire', + 'close-readiness' + ] as const)('ends previously positive continuation at actual %s', async (stage) => { + const closing = stage.startsWith('close-') + const boundary = stage.replace('close-', '') + const { host, adapter, codex, turns, permissionSend, pick, stop, starts } = current() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + await pick('auto') + const { sessions, conversationDelivery } = host.collaboratorsForTests() + const journal = sessions.get(SESSION)!.journal + const original = sessions.get(SESSION)!.child! + const deps = conversationDelivery.loop['deps'] + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + const outcomes: Promise[] = [] + const retryEntered = Promise.withResolvers() + const retryReady = Promise.withResolvers() + const opened = vi.spyOn(adapter, 'open') + const start = deps.startProviderChild + vi.spyOn(deps, 'startProviderChild').mockImplementation((...args) => { + const claim = start(...args) + outcomes.push(claim.outcome) + const outcome = claim.outcome.then(async (result) => { + if (boundary === 'readiness' && result.ok) { + expect(sessions.get(SESSION)?.child?.phase).toBe('ready') + entered.resolve() + await held.promise + } + return result + }) + outcomes.push(outcome) + return { ...claim, outcome } + }) + const dispatch = adapter.dispatch.bind(adapter) + let signal: AbortSignal | undefined + const signals: (AbortSignal | undefined)[] = [] + vi.spyOn(adapter, 'dispatch').mockImplementation((input) => { + signal = input.signal + 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 open = codex.routes['thread/start']! + const opening = vi.fn(async (params: Parameters[0]) => { + if (boundary === 'acquire' || boundary === 'starting-exit') { + if (boundary === 'starting-exit' && codex.connections.length > 2) { + retryEntered.resolve() + await retryReady.promise + } else { + entered.resolve() + await held.promise + } + } + return open(params) + }) + codex.routes['thread/start'] = codex.routes['thread/resume'] = opening + const resolve = journal.resolveDispatch.bind(journal) + let reboundFence: number | undefined + const rebound = vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { + if ( + boundary === 'rebind' && + args[0].state === 'pending' && + args[0].fence !== original.fence && + args[0].fence === sessions.get(SESSION)?.child?.fence + ) { + reboundFence = args[0].fence + entered.resolve() + await held.promise + } + return resolve(...args) + }) + async function drainForwarded() { + await Promise.allSettled(retired.mock.results.map((result) => result.value)) + await Promise.allSettled(outcomes) + const results = [...opening.mock.results, ...opened.mock.results, ...rebound.mock.results] + await Promise.allSettled( + results.map((result) => (result.type === 'return' ? result.value : undefined)) + ) + } + const id = await permissionSend('same original input', { personality: 'friendly' }) + const waiting = host.waitForSendSettlement(SESSION, id) + const originalBody = hostTestMessage('same original input') + // Ready replacement-child exit proof settles before lease release; terminal rows keep that fence. + const settlesAtChildFence = ['readiness', 'rebind'].includes(boundary) + let boundFence = journal.submission(id)!.fence + let stopping: Promise = Promise.resolve() + try { + await entered.promise + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(signal?.aborted).toBe(false) + const child = sessions.get(SESSION)?.child + expect(journal.submission(id)).toMatchObject({ + clientMessageId: id, + options: { personality: 'friendly' } + }) + expect(journal.itemBody(agentJournalSubmissionKey(id))).toEqual(originalBody) + boundFence = child?.fence ?? journal.submission(id)!.fence + if (boundary === 'rebind') { + expect(reboundFence).toBe(child!.fence) + } + if (child) { + expect(child).toMatchObject({ + fence: current().conversationFence(), + generation: boundary === 'retire' ? 'generation-1' : 'generation-2' + }) + } + expect(boundFence).toBe(boundary === 'retire' ? original.fence : original.fence + 2) + if (boundary === 'starting-exit') { + const originalSignal = signal + const owner = structuredAgentSessionHandingOver(journal).get(id)?.input?.owner + expect(owner).toEqual(expect.any(Function)) + codex.connections[1]!.handlers.onExit?.(new Error('startup exit'), { expected: false }) + await retryEntered.promise + expect(sessions.get(SESSION)?.lastEndedChild).toMatchObject({ + fence: child!.fence, + generation: child!.generation, + rootGone: true + }) + expect(originalSignal?.aborted).toBe(false) + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(structuredAgentSessionHandingOver(journal).get(id)?.input?.owner).toBe(owner) + expect(journal.submission(id)).toMatchObject({ + dispatchState: 'pending', + fence: original.fence + }) + expect(starts()).toHaveLength(0) + await pick('ask') + const replacement = sessions.get(SESSION)!.child! + expect(replacement.fence).toBe(current().conversationFence()) + expect(replacement.generation).not.toBe(child!.generation) + retryReady.resolve() + held.resolve() + await vi.waitFor(() => expect(starts()).toHaveLength(1)) + expect(replacement.phase).toBe('ready') + expect(starts()[0]?.params).toMatchObject({ + clientUserMessageId: id, + input: [{ type: 'text', text: 'same original input' }], + personality: 'friendly', + approvalPolicy: 'on-request', + sandboxPolicy: { type: 'readOnly' } + }) + turns.start() + turns.echo(id) + const answered = current().submission((await waiting)?.value) + expect(answered).toMatchObject({ + clientMessageId: id, + dispatchState: 'accepted', + fence: replacement.fence, + options: { personality: 'friendly' } + }) + expect(answered).toEqual(journal.submission(id)) + expect(journal.itemBody(agentJournalSubmissionKey(id))).toEqual(originalBody) + await current().waitForInputFinalization(journal, id) + expect(signals.every((observed) => observed === originalSignal)).toBe(true) + expect(originalSignal?.aborted).toBe(false) + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(false) + expect(starts()).toHaveLength(1) + expect(codex.connections).toHaveLength(3) + return + } + stopping = closing ? host.close(SESSION, 'user-close') : stop() + expect(signal?.aborted).toBe(true) + expect(continuingStructuredAgentSessionInput(journal, id)).toBe(false) + const rowFence = journal.submission(id)!.fence + held.resolve() + retryReady.resolve() + await stopping + await drainForwarded() + await current().waitForInputFinalization(journal, id) + await journal['queue'].drain() + if (closing) { + const row = journal.submission(id) + const cards = journal.queuedMessages.list() + expect(rowFence).toBe(original.fence) + expect(boundFence).toBe(original.fence + 2) + expect(starts()).toHaveLength(0) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false) + expect(cards.map((card) => card.messageId)).toEqual([id]) + expect(current().submission((await waiting)?.value)).toEqual(row) + expect(cards[0]).toMatchObject({ + body: originalBody, + options: { personality: 'friendly' }, + position: 0 + }) + expect(row).toMatchObject({ + keptAsQueuedMessageId: id, + source: { kind: 'user' }, + rejection: { kind: 'chatClosed' } + }) + return + } + expect(journal.submission(id)).toMatchObject({ + clientMessageId: id, + dispatchState: 'rejected', + fence: settlesAtChildFence ? boundFence : current().conversationFence(), + rejection: { kind: 'cancelled' }, + options: { personality: 'friendly' } + }) + if (boundary === 'retire') { + expect(current().conversationFence()).toBe(boundFence + 1) + expect(rebound.mock.calls).toContainEqual([ + expect.objectContaining({ + clientMessageId: id, + state: 'rejected', + fence: current().conversationFence() + }) + ]) + } + expect(current().submission((await waiting)?.value)).toEqual(journal.submission(id)) + expect(journal.itemBody(agentJournalSubmissionKey(id))).toEqual(originalBody) + expect(journal.submission(id)?.reason).toMatch(/provider_cancelled_before_start/) + expect(journal.submission(id)?.rejection).not.toHaveProperty('neverReachedAgent') + expect(starts()).toHaveLength(0) + expect(codex.connections).toHaveLength(boundary === 'retire' ? 1 : 2) + } finally { + held.resolve() + retryReady.resolve() + await stopping + await drainForwarded() + await current().waitForInputFinalization(journal, id) + const writes = await Promise.allSettled(rebound.mock.results.map((result) => result.value)) + expect(writes.filter((result) => result.status === 'rejected')).toEqual([]) + vi.restoreAllMocks() + } + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts new file mode 100644 index 00000000000..3be7b4aba81 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts @@ -0,0 +1,796 @@ +import './structured-agent-session-codex-input-admission.spec' +import { + continuingStructuredAgentSessionInput, + finalizingStructuredAgentSessionInput, + structuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' +import { measureCodexInputWork } from './structured-agent-session-codex-input-perf-test-support' +import { registerCodexInputFinalization } from './structured-agent-session-codex-input-finalization.spec' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error' +import { codexStructuredPermissionPolicy } from '../../codex/codex-structured-permission-policy' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentSessionSendSubmission as submission } from '../../../shared/agent-session-wire' +import { AGENT_JOURNAL_THREAD_SCOPE as turnScope } from '../../../shared/agent-session-journal-types' + +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { mkdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { CodexAppServerTimeoutError } from '../../codex/codex-app-server-session' +import { + THREAD_ID as THREAD, + adapterFor, + fakeCodex +} from '../../codex/codex-structured-session-adapter-fixture' +import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createRestTestRig } from './structured-agent-session-rest-test-rig' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +const CALLER = { callerKey: 'client-1' } + +const platform = process.platform +let retained = false +const config = { + config: { + personality: 'friendly', + service_tier: 'default', + sandbox_workspace_write: { + writable_roots: ['/extra'], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true + } + } +} +const sandbox = { + type: 'workspaceWrite', + writableRoots: ['/extra'], + networkAccess: true, + excludeTmpdirEnvVar: true, + excludeSlashTmp: true +} +let events: AgentSessionSubscribeEvent[] +let root: string +let host: StructuredAgentSessionHost +let turns: ReturnType +let codex: ReturnType +let notify: (method: string, params: unknown) => void +let launch: { resumeThreadId?: string | null } +let adapter: CodexStructuredSessionAdapter + +beforeEach(async () => { + events = [] + retained = false + codex = fakeCodex({ + 'config/read': () => config, + 'thread/goal/get': () => ({ goal: null }), + 'configRequirements/read': () => ({ requirements: null }) + }) + codex.routes['thread/start'] = codex.routes['thread/resume'] = (params) => ({ + thread: { id: THREAD }, + model: 'gpt-live', + reasoningEffort: 'medium', + sandbox: + params?.sandbox === 'danger-full-access' + ? { type: 'dangerFullAccess' } + : params?.sandbox === 'read-only' + ? { type: 'readOnly' } + : sandbox + }) + 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 }) => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: + launch.resumeThreadId ?? + store.getRecord(identity.sessionId)?.providerHandleChain.at(-1)?.handle.nativeId ?? + null, + ...(permissionMode === 'own' + ? {} + : { permissionPolicy: codexStructuredPermissionPolicy(permissionMode ?? 'ask') }) + }), + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onEvent: (event) => { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { + void host.handleAdapterEvent(event) + } + } + }) + const rig = await createRestTestRig({ + agents: claudeAndCodexDeclared(), + logger: recordingStructuredAgentSessionLogger().logger, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + mintSpawnToken: () => 'spawn-1', + hasOpenDispatch: () => retained, + probeOwner: undefined, + statusSink: undefined, + idleSweep: undefined + }) + root = rig.root + host = rig.host + const store = rig.store + expect( + await host.attach( + CALLER, + hostTestAttachParams(null, { options: { permissionMode: 'full-access' } }) + ) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child?.phase).toBe('ready')) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await adapter.closeAll() + await host.flushAllStreamedEvents() + closeTestJournalHostDatabase(root) + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + await rm(root, { recursive: true, force: true }) +}) + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} +async function pick(value: string) { + const fields = { key: 'permissionMode', value } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true }) +} +async function permissionSend(text: string, options?: Record) { + const fields = { body: hostTestMessage(text), ...(options ? { options } : {}) } + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', fields), + userSend: true, + ...fields + }) + if (!result.ok) { + throw new Error(JSON.stringify(result.refusal)) + } + return result.value.clientMessageId +} +const row = async (id: string) => + (await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === id) +const starts = () => + codex.connections.flatMap((c) => c.calls.filter((r) => r.method === 'turn/start')) +async function began(id: string) { + await vi.waitFor(() => + expect(starts().some((r) => r.params?.clientUserMessageId === id)).toBe(true) + ) + turns.start() + turns.echo(id) + await vi.waitFor(async () => expect(await row(id)).toMatchObject({ dispatchState: 'accepted' })) +} + +it('keeps only the original pre-send result when a ready child exits during Auto preparation', async () => { + await pick('ask') + await began(await permissionSend('completed warm turn')) + turns.end('completed') + await host.flushStreamedEvents(SESSION) + const { sessions } = host.collaboratorsForTests() + const journal = sessions.get(SESSION)!.journal + const warm = journal.snapshot().items.find((item) => item.body.kind === 'turn')! + expect(warm.body).toMatchObject({ state: 'completed', outcome: 'success' }) + const child = sessions.get(SESSION)!.child! + expect(child.phase).toBe('ready') + const preparation = Promise.withResolvers() + const read = vi.fn(() => preparation.promise) + codex.routes['config/read'] = read + await pick('auto') + const id = await permissionSend('preserve this unsent original') + const waiting = host.waitForSendSettlement(SESSION, id) + try { + await vi.waitFor(() => expect(read).toHaveBeenCalledOnce()) + expect(adapter.heldSends(SESSION)).toContain(id) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(journal.submission(id)).toMatchObject({ dispatchState: 'pending', fence: child.fence }) + expect(starts()).toHaveLength(1) + codex.connections[0]!.handlers.onExit?.(new Error('ready Codex exited during preparation'), { + expected: false + }) + await codexInputFinalizationFixture().waitForInputFinalization(journal, id) + const result = submission((await waiting)?.value) + expect(result).toMatchObject({ + clientMessageId: id, + dispatchState: 'rejected', + rejection: { kind: 'providerExited', neverReachedAgent: true }, + providerItemId: null, + answeredInTurn: null + }) + expect(result).toEqual(journal.submission(id)) + expect(journal.itemBody(agentJournalSubmissionKey(id))).toEqual( + hostTestMessage('preserve this unsent original') + ) + await vi.waitFor(() => expect(sessions.get(SESSION)?.child).toBeNull()) + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.items.find((item) => item.itemId === warm.itemId)).toEqual(warm) + expect( + snapshot.items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerExited' + ) + ).toEqual([]) + expect(starts()).toHaveLength(1) + expect(codex.connections).toHaveLength(1) + } finally { + preparation.resolve(config) + await host.flushAllStreamedEvents() + } +}) + +it.each(['config', 'order', 'initial'] as const)( + 'acquires idle late Auto after the %s wait before writing once', + async (wait) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const paused = Promise.withResolvers() + const order = + wait === 'order' + ? host.collaboratorsForTests().runtimeState.deliveryOrder.reserve(SESSION) + : null + if (wait !== 'order') { + codex.routes['config/read'] = () => paused.promise + } + const delivery = host.collaboratorsForTests().runtimeState.deliveryOrder + const entered = Promise.withResolvers() + const beforeSend = delivery.beforeSend.bind(delivery) + vi.spyOn(delivery, 'beforeSend').mockImplementation((...args) => { + entered.resolve() + return beforeSend(...args) + }) + const old = codex.connections[0]! + if (wait === 'initial') { + await pick('auto') + } + const deps = host.collaboratorsForTests().conversationDelivery.loop['deps'] + const ran = deps.recordRanOptions! + const recorded = vi.fn() + vi.spyOn(deps, 'recordRanOptions').mockImplementation((id, fence) => { + const recorder = ran(id, fence) + const record: typeof recorder = async (options) => { + recorded(id, fence, options) + await recorder(options) + } + return Object.assign(record, recorder) + }) + const id = await permissionSend('keep my words', { personality: 'friendly' }) + if (wait === 'order') { + await entered.promise + expect(isQueuedAgentJournalSubmission((await row(id))!)).toBe(true) + expect(starts()).toHaveLength(0) + } else { + await vi.waitFor(() => expect(old.calls.some((r) => r.method === 'config/read')).toBe(true)) + } + await pick('auto') + const compiling = Promise.withResolvers() + codex.routes['thread/start'] = codex.routes['thread/resume'] = () => compiling.promise + paused.resolve(config) + order?.release() + await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) + const acquiring = codex.connections[1]! + const replacement = host['sessions'].get(SESSION)!.child! + expect(replacement.fence).toBe(host['deps'].store.getRecord(SESSION)!.lease.runtimeFence) + await vi.waitFor(() => + expect(acquiring.calls.some((r) => r.method.startsWith('thread/'))).toBe(true) + ) + expect(await row(id)).toMatchObject({ dispatchState: 'pending' }) + expect(host.currentWork(SESSION)?.owesSend((await row(id))!)).toBe(true) + let answered = false + const settled = host.waitForSendSettlement(SESSION, id).then((value) => { + answered = true + return value + }) + await pick('ask') + compiling.resolve({ + thread: { id: THREAD }, + model: 'gpt-live', + reasoningEffort: 'medium', + sandbox + }) + await vi.waitFor(() => expect(starts()).toHaveLength(1)) + expect(old.calls.filter((r) => r.method === 'turn/start')).toHaveLength(0) + expect(acquiring.calls.find((r) => r.method.startsWith('thread/'))?.params).toMatchObject({ + approvalPolicy: 'on-request' + }) + expect(starts()[0]?.params).toMatchObject({ + clientUserMessageId: id, + input: [{ type: 'text', text: 'keep my words' }], + personality: 'friendly', + approvalPolicy: 'on-request', + sandboxPolicy: { type: 'readOnly' } + }) + expect(answered).toBe(false) + turns.start() + turns.echo(id) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.runtimePermission).toEqual({ + fence: replacement.fence, + mode: 'ask' + }) + ) + expect((await host.readOptions(SESSION)).current.reportedPermissionMode).toBe('ask') + expect(submission((await settled)?.value)).toMatchObject({ + dispatchState: 'accepted', + fence: replacement.fence + }) + await vi.waitFor(() => + expect(recorded).toHaveBeenCalledWith( + SESSION, + replacement.fence, + expect.objectContaining({ message: { personality: 'friendly' } }) + ) + ) + await vi.waitFor(() => + expect(host['deps'].store.getRecord(SESSION)?.options).toMatchObject({ + model: 'gpt-live', + effort: 'medium' + }) + ) + } +) + +it.each([ + ['ask', 1], + ['auto', 1], + ['auto-before', 1], + ['empty', 1], + ['auto', 10], + ['auto', 100] +] as const)( + 'refreshes stopped words after refused steering with %s and %i stopped messages, including replacement', + async (mode, population) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const running = await permissionSend('running') + await began(running) + const stoppedIds = Array.from({ length: population }, (_, index) => + index === 0 ? 'stopped-words' : `stopped-words-${index}` + ) + for (const [index, stopped] of stoppedIds.entries()) { + await journal.appendSubmission({ + clientMessageId: stopped, + payloadFingerprint: 'stopped', + body: hostTestMessage(index === 0 ? 'words that were not delivered' : stopped), + fence: 1, + source: { kind: 'user' }, + origin: 'client', + handoverRecorded: true + }) + if (index > 0) { + await journal.resolveDispatch({ + clientMessageId: stopped, + state: 'pending', + fence: 1, + turnScope: { kind: 'thread' }, + carries: [stoppedIds[index - 1]!] + }) + } + await journal.resolveDispatch({ + clientMessageId: stopped, + state: 'rejected', + fence: 1, + ...agentSessionFailureWords( + agentSessionFailureFact('cancelled', { neverReachedAgent: true }), + { surface: 'rejection' } + ) + }) + } + const stopped = stoppedIds[0]! + const refusal = Promise.withResolvers() + codex.routes['turn/steer'] = () => refusal.promise + await host.flushStreamedEvents(SESSION) + const measured = + population > 1 ? measureCodexInputWork(host, events, 'history', population) : undefined + let waiting: ReturnType | undefined + try { + if (mode === 'auto-before') { + await pick('auto') + } + const id = await permissionSend('follow up') + if (measured) { + waiting = host.waitForSendSettlement(SESSION, id) + void waiting.catch(() => {}) + } + await vi.waitFor(() => + expect(codex.connections[0]?.calls.some((r) => r.method === 'turn/steer')).toBe(true) + ) + if (mode !== 'auto-before') { + await pick(mode === 'empty' ? 'ask' : mode) + } + if (mode === 'empty') { + await journal.resolveDispatch({ + clientMessageId: id, + state: 'pending', + fence: 1, + turnScope: { kind: 'thread' }, + carries: [stopped] + }) + await journal.appendSubmission({ + clientMessageId: 'other-carrier', + payloadFingerprint: 'other-carrier', + body: hostTestMessage('history delivered elsewhere'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'other-carrier', + state: 'pending', + fence: 1, + turnScope, + carries: [stopped] + }) + await journal.resolveDispatch({ + clientMessageId: 'other-carrier', + state: 'accepted', + fence: 1, + providerIdentity: null + }) + } + turns.end('completed') + await host.flushStreamedEvents(SESSION) + refusal.reject( + new CodexAppServerRequestError('turn/steer', -32600, 'no active turn', 'no active turn') + ) + await vi.waitFor(() => expect(starts()).toHaveLength(2)) + expect(codex.connections).toHaveLength(mode.startsWith('auto') ? 2 : 1) + expect(starts()[1]?.params).toMatchObject({ clientUserMessageId: id }) + expect( + JSON.stringify(starts()[1]?.params?.input).includes('words that were not delivered') + ).toBe(mode !== 'empty') + expect(journal.stoppedHistoryCarried().get(id)).toEqual( + mode === 'empty' ? undefined : stoppedIds + ) + const item = journal + .snapshot() + .items.find((item) => item.itemId === agentJournalSubmissionKey(id)) + expect(item?.turnScope).toEqual({ kind: 'thread' }) + expect(journal.itemBody(agentJournalSubmissionKey(id))).toEqual(hostTestMessage('follow up')) + for (const stoppedId of stoppedIds.slice(1)) { + expect(JSON.stringify(starts()[1]?.params?.input)).toContain(stoppedId) + } + if (waiting) { + await began(id) + expect(submission((await waiting)?.value)).toMatchObject({ + clientMessageId: id, + dispatchState: 'accepted' + }) + expect(host['clientDelivery']['sendSettlement']['waiters'].has(SESSION)).toBe(false) + } + } finally { + refusal.resolve({}) + host['clientDelivery']['sendSettlement'].closeSession(SESSION) + await waiting?.catch(() => {}) + await measured?.() + } + } +) + +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) => { + const collaborators = host.collaboratorsForTests() + const session = collaborators.sessions.get(SESSION)! + const journal = session.journal + const hostChild = session.child! + const provider = adapter['sessions'].get(SESSION)! + const translator = provider.translator + Object.defineProperty(provider.connection, 'closeResult', { + get: () => (provider.connection.closed ? { root: 'exited', tree: 'exited' } : undefined) + }) + const echoed = vi.spyOn(provider.dispatchEchoes, 'settle') + const persisted = vi.spyOn(journal, 'resolveDispatch') + const accepted = vi.spyOn(journal, 'acceptLate') + const settledLate = vi.spyOn(host, 'settleLateDispatch') + const deps = collaborators.conversationDelivery.loop['deps'] + const ordered = deps.serialize + const lost = Promise.withResolvers() + const responseObserved = lost.promise.catch(() => {}) + const replyLost = new CodexAppServerTimeoutError('reply lost after the write') + const entered = Promise.withResolvers() + const delivery = Promise.withResolvers() + const frameDelivery = Promise.withResolvers() + const forwarded: Promise[] = [] + codex.routes['turn/start'] = () => lost.promise + let stopping: Promise | undefined + let frameEnded = false + let delivered = false + const dispatch = adapter.dispatch.bind(adapter) + let restoreSerialize: (() => void) | undefined + const dispatched = vi.spyOn(adapter, 'dispatch').mockImplementation(async (input) => { + const serialized = vi.spyOn(deps, 'serialize').mockImplementation((sessionId, task) => { + const invoked = ordered(sessionId, task) + const resultDelivery = invoked.then(async (result) => { + if (result && typeof result === 'object' && 'kind' in result && result.kind === 'sent') { + expect(result).toMatchObject({ kind: 'sent', response: expect.any(Promise) }) + expect(starts()[0]?.params?.clientUserMessageId).toBe(input.clientMessageId) + entered.resolve() + await delivery.promise + delivered = true + } + return result + }) + forwarded.push(invoked, resultDelivery) + return resultDelivery + }) + restoreSerialize = () => serialized.mockRestore() + try { + return await dispatch(input) + } finally { + if (cancellation === 'stop') { + await frameDelivery.promise + } + frameEnded = true + } + }) + const id = await permissionSend('one actual write') + const waiting = host.waitForSendSettlement(SESSION, id) + const waiterObserved = vi.fn() + const waiterObserver = waiting.then(waiterObserved, waiterObserved) + try { + await entered.promise + expect(starts()).toHaveLength(1) + expect(await row(id)).toMatchObject({ dispatchState: 'pending' }) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(frameEnded).toBe(false) + expect(waiterObserved).not.toHaveBeenCalled() + expect(delivered).toBe(false) + if (cancellation === 'stop') { + stopping = stop() + } else { + expect( + collaborators.runtimeState.acquireAborts.abort(SESSION, 'local wait cancelled') + ).toEqual({ reached: true, handedOver: true }) + } + await vi.waitFor(() => expect(dispatched.mock.calls[0]?.[0].signal?.aborted).toBe(true)) + expect(delivered).toBe(false) + expect(frameEnded).toBe(false) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + delivery.resolve() + lost.reject(replyLost) + if (cancellation === 'stop') { + expect(await stopping).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(session.child).toBeNull()) + expect(session.lastEndedChild).toMatchObject({ + fence: hostChild.fence, + generation: hostChild.generation, + cause: 'user-stop', + rootGone: true + }) + expect(provider.ended).toBe(true) + expect(provider.connection.closed).toBe(true) + expect(turns.turnId).toBeNull() + expect(frameEnded).toBe(false) + expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(true) + expect(submission((await waiting)?.value)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'cancelled' }, + recovered: true + }) + expect( + (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.text === 'Cancellation requested.' + ) + ).toHaveLength(1) + } else { + expect(submission((await waiting)?.value)).toMatchObject({ dispatchState: 'unknown' }) + expect(session.child).toBe(hostChild) + expect(session.lastEndedChild).toBeUndefined() + expect(journal.queuedMessages.userStopInForce()).toBeNull() + expect(adapter['sessions'].get(SESSION)).toBe(provider) + expect(provider.connection).toBe(codex.connections[0]) + expect(provider.connection.closed).toBe(false) + expect(codex.connections[0]?.closeCount).toBe(0) + expect(provider.ended).toBe(false) + expect(provider.translator).toBe(translator) + expect(translator).not.toBeNull() + turns.routes['turn/start']() + turns.start() + turns.echo(id) + await vi.waitFor(async () => + expect(await row(id)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:turn-1:0` + }) + ) + expect(echoed).toHaveBeenCalledWith(id) + expect(echoed.mock.results.some((result) => result.value === true)).toBe(true) + expect(accepted).toHaveBeenCalledTimes(1) + expect(settledLate).toHaveBeenCalledWith({ + sessionId: SESSION, + clientMessageId: id, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + } + frameDelivery.resolve() + await vi.waitFor(() => expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false)) + expect(structuredAgentSessionHandingOver(journal).has(id)).toBe(false) + expect(dispatched).toHaveBeenCalledTimes(1) + expect(starts()).toHaveLength(1) + expect(codex.connections).toHaveLength(1) + expect(frameEnded).toBe(true) + await waiterObserver + expect(waiterObserved).toHaveBeenCalledTimes(1) + } finally { + delivery.resolve() + frameDelivery.resolve() + lost.reject(replyLost) + const frames = await Promise.allSettled(dispatched.mock.results.map((result) => result.value)) + await vi.waitFor(() => expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false)) + const drains = await Promise.allSettled([ + responseObserved, + waiterObserver, + stopping, + ...forwarded, + ...settledLate.mock.results.map((result) => result.value), + ...accepted.mock.results.map((result) => result.value), + ...persisted.mock.results.map((result) => result.value) + ]) + await host.flushStreamedEvents(SESSION) + restoreSerialize?.() + dispatched.mockRestore() + settledLate.mockRestore() + accepted.mockRestore() + persisted.mockRestore() + echoed.mockRestore() + expect(frames).toEqual([{ status: 'rejected', reason: replyLost }]) + expect(drains.filter((result) => result.status === 'rejected')).toEqual([]) + } + } +) + +async function assertInputRecoveredAfterSnapshot(id: string): Promise { + const journal = host['sessions'].get(SESSION)!.journal + const snapshotRoot = join(root, 'crash') + await mkdir(snapshotRoot) + await openTestJournalHostDatabase(root).db.backup(journalDatabasePath(snapshotRoot)) + const fresh = fakeCodex() + const restartedAdapter = adapterFor(fresh) + const restarted = new StructuredAgentSessionHost({ + ...host['deps'], + adapter: restartedAdapter, + store: await openTestAgentSessionRecordStore(snapshotRoot), + journalDatabase: openTestJournalHostDatabase(snapshotRoot), + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + try { + await restarted.restoreReadableSessions() + const recovered = await restarted.journalSnapshot(SESSION) + const reopened = restarted.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(reopened).not.toBe(journal) + expect(continuingStructuredAgentSessionInput(reopened, id)).toBe(false) + expect(recovered.submissions.find((s) => s.clientMessageId === id)).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + expect(fresh.connections).toHaveLength(0) + } finally { + await restarted.flushAllStreamedEvents() + await restartedAdapter.closeAll() + closeTestJournalHostDatabase(snapshotRoot) + } +} + +export function codexInputFinalizationFixture() { + return { + host, + adapter, + codex, + config, + turns, + permissionSend, + began, + submission, + stop, + pick, + row, + starts, + assertInputRecoveredAfterSnapshot, + conversationFence: () => host['deps'].store.getRecord(SESSION)!.lease.runtimeFence, + waitForInputFinalization: (journal: AgentSessionJournal, id: string) => + vi.waitFor(() => expect(finalizingStructuredAgentSessionInput(journal, id)).toBe(false)), + measureInputWork: (kind: 'history' | 'close', population: number) => + measureCodexInputWork(host, events, kind, population) + } +} + +registerCodexInputFinalization(codexInputFinalizationFixture) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-perf-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-perf-test-support.ts new file mode 100644 index 00000000000..d93d5252c40 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-perf-test-support.ts @@ -0,0 +1,159 @@ +import { expect, vi } from 'vitest' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +const inputWorkSamples = new Map>() +export function measureCodexInputWork( + host: StructuredAgentSessionHost, + events: AgentSessionSubscribeEvent[], + kind: 'history' | 'close', + population: number +) { + const { sessions, conversationDelivery } = host.collaboratorsForTests() + const journal = sessions.get(SESSION)!.journal + const copies = vi.spyOn(journal, 'submissions') + const settlement = host['clientDelivery']['sendSettlement'] + const published = vi.spyOn(settlement, 'publish') + const reportSamples: { + currentChild: boolean + waiters: number + pending: boolean + remaining: number + publishes: number + submissions: number + }[] = [] + const delivered: Promise[] = [] + const handle = host.handleAdapterEvent + const reports = vi.spyOn(host, 'handleAdapterEvent').mockImplementation((event) => { + if ( + kind !== 'history' || + event.type !== 'options-reported' || + event.reportedOptions.reportedPermissionMode !== 'auto' + ) { + return handle(event) + } + const child = sessions.get(SESSION)?.child + const waiters = settlement['waiters'].get(SESSION) + const waiter = waiters?.values().next().value + const before = { + currentChild: + child?.phase === 'ready' && + child.fence === event.fence && + child.generation === event.acquisitionGeneration, + waiters: waiters?.size ?? 0, + pending: journal.submission(waiter?.clientMessageId ?? '')?.dispatchState === 'pending' + } + const firstCopy = copies.mock.results.length + const firstPublish = published.mock.calls.length + const delivery = handle(event) + reportSamples.push({ + ...before, + remaining: waiters?.size ?? 0, + publishes: published.mock.calls.length - firstPublish, + submissions: copies.mock.results + .slice(firstCopy) + .reduce((sum, row) => sum + (row.type === 'return' ? row.value.length : 0), 0) + }) + delivered.push(delivery) + return delivery + }) + const bodies = vi.spyOn(journal, 'itemBody') + const cards = vi.spyOn(journal.queuedMessages, 'list') + const plans = vi.spyOn(journal.queuedMessages, 'plan') + const transactions = vi.spyOn(journal['database'], 'transaction') + const commits = vi.spyOn(journal['rowWriter']['deps'], 'commit') + let historyEntries = 0 + let comparisons = 0 + const history = journal.stoppedHistoryCarried + const histories = vi.spyOn(journal, 'stoppedHistoryCarried').mockImplementation(() => { + const value = history() + historyEntries += value.size + return value + }) + const includes = Array.prototype.includes + const membership = vi.spyOn(Array.prototype, 'includes').mockImplementation(function ( + this: unknown[], + value: unknown, + fromIndex?: number + ) { + const found = includes.call(this, value, fromIndex) + if (this[0] === 'perf-kept-0' && fromIndex === undefined) { + const index = this.indexOf(value) + comparisons += index === -1 ? this.length : index + 1 + } + return found + }) + const firstEvent = events.length + return async () => { + try { + await conversationDelivery.loop['runs'].get(SESSION) + await journal['queue'].drain() + await host.flushStreamedEvents(SESSION) + await Promise.all(delivered) + const work = { + submissions: copies.mock.results.reduce( + (sum, row) => sum + (row.type === 'return' ? row.value.length : 0), + 0 + ), + history: historyEntries, + reportSubmissions: reportSamples.reduce((sum, report) => sum + report.submissions, 0), + bodies: bodies.mock.calls.length, + cards: cards.mock.results.reduce( + (sum, row) => sum + (row.type === 'return' ? row.value.length : 0), + 0 + ), + transactions: transactions.mock.calls.length, + rows: commits.mock.calls.reduce((sum, [rows]) => sum + rows.length, 0), + publicationBytes: Buffer.byteLength(JSON.stringify(events.slice(firstEvent))) + } + expect(work.submissions).toBeGreaterThanOrEqual(population) + expect(work.bodies).toBeGreaterThan(0) + expect(work.transactions).toBeGreaterThan(0) + expect(work.rows).toBeGreaterThan(0) + expect(events.length).toBeGreaterThan(firstEvent) + if (kind === 'history') { + expect(historyEntries).toBeGreaterThanOrEqual(population - 1) + expect(reportSamples).toHaveLength(1) + expect(reportSamples[0]).toMatchObject({ + currentChild: true, + waiters: 1, + pending: true, + remaining: 1, + publishes: 1 + }) + expect(work.reportSubmissions).toBeGreaterThanOrEqual(population + 2) + } else { + expect(plans.mock.calls.reduce((sum, [changes]) => sum + changes.length, 0)).toBe( + 2 * population + 4 + ) + expect(comparisons).toBe(population * population + 6 * population + 3) + expect(work.cards).toBeGreaterThanOrEqual(population) + } + if (population === 100) { + const small = inputWorkSamples.get(kind)! + expect(small).toBeDefined() + for (const [field, count] of Object.entries(work)) { + expect(count, field).toBeLessThanOrEqual(12 * Math.max(1, small[field]!)) + } + } else { + inputWorkSamples.set(kind, work) + } + } finally { + for (const mock of [ + reports, + published, + copies, + bodies, + cards, + plans, + transactions, + commits, + histories, + membership + ]) { + mock.mockRestore() + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime-host.ts index 57b3e277325..eb97972624c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime-host.ts @@ -1,6 +1,11 @@ +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' export type StructuredAgentSessionConversationLifetimeHost = { /** Resolve lazily because host collaborators are assigned after construction. */ @@ -14,3 +19,32 @@ export type StructuredAgentSessionConversationLifetimeHost = { closeStatus: (sessionId: string, options: { listed: boolean }) => void childWorkOpen: (sessionId: string) => boolean } + +export function createStructuredAgentSessionStopStartingChild( + host: StructuredAgentSessionConversationLifetimeHost +) { + return (sessionId: string, expired: StructuredAgentSessionProviderChildIdentity | null): void => { + void host + .serialize(sessionId, async () => { + const child = host.sessions.get(sessionId)?.child + if ( + !expired || + !child || + child.phase !== 'starting' || + !sameProviderChild(child, expired) + ) { + return + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop' + }) + }) + .catch((error: unknown) => + host.context().deps.logger.warn('stopping an agent that could not finish starting failed', { + scope: 'startup-limit', + sessionId, + error + }) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0fe53dad12f..d8ff2f1bee6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -1,7 +1,12 @@ -import type { StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host' +import { + createStructuredAgentSessionStopStartingChild, + type StructuredAgentSessionConversationLifetimeHost +} from './structured-agent-session-conversation-lifetime-host' import { retryableStructuredAgentSessionStart } from './structured-agent-session-start-retry' // Open conversations at rest; never start agents for reads. Acquire serialization once because the queue is nonreentrant. +export type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-host-types' +import { closeStructuredAgentSessionHandovers } from './structured-agent-session-handing-over' import { AgentSessionRefusalError, agentSessionRefusalError @@ -9,6 +14,7 @@ import { import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import { releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' import { + beginFinalizedStructuredAgentSessionClose, holdClosedStructuredAgentSessionSends, closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, @@ -40,10 +46,6 @@ import { } from './structured-agent-session-start-failure-row' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -export type StructuredAgentSessionConversationLifetime = ReturnType< - typeof createStructuredAgentSessionConversationLifetime -> - export function createStructuredAgentSessionConversationLifetime( host: StructuredAgentSessionConversationLifetimeHost ) { @@ -75,6 +77,8 @@ export function createStructuredAgentSessionConversationLifetime( ) } + const closeContext = { sessions, serialize, closeConversation, deps } + const hasOpenDispatch = (sessionId: string): boolean => { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true @@ -88,6 +92,7 @@ export function createStructuredAgentSessionConversationLifetime( onCloseError?: (error: unknown) => void ): Promise => { const session = sessions.get(sessionId) + const finishClose = beginFinalizedStructuredAgentSessionClose(closeContext, sessionId, session) if (session) { // Settle and pause before Stop so closed work cannot deliver automatically even if the handle stays open. await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { @@ -95,6 +100,7 @@ export function createStructuredAgentSessionConversationLifetime( }) } const result = await stop() + finishClose?.() try { await closeConversation(sessionId) } catch (error) { @@ -118,6 +124,10 @@ export function createStructuredAgentSessionConversationLifetime( const starts = workspaceSleepStartAborts(() => host.context().runtimeState, sessionId) // Pending creates have no record yet; their queued close waits for founding. if (matches()) { + const journal = sessions.get(sessionId)?.journal + if (journal) { + closeStructuredAgentSessionHandovers(journal) + } starts.abort() } const closed = serialize(sessionId, async () => { @@ -190,26 +200,7 @@ export function createStructuredAgentSessionConversationLifetime( ...deps().idleSweep }) - const stopStartingChild = ( - sessionId: string, - expired: StructuredAgentSessionProviderChildIdentity | null - ): void => { - void serialize(sessionId, async () => { - const child = sessions.get(sessionId)?.child - if (!expired || !child || child.phase !== 'starting' || !sameProviderChild(child, expired)) { - return - } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'host-stop' - }) - }).catch((error: unknown) => - deps().logger.warn('stopping an agent that could not finish starting failed', { - scope: 'startup-limit', - sessionId, - error - }) - ) - } + const stopStartingChild = createStructuredAgentSessionStopStartingChild(host) return { closeForWorkspaceSleep, idleSweep, @@ -308,6 +299,10 @@ export function createStructuredAgentSessionConversationLifetime( }, /** Close resources while retaining durable chat/history and personal unsent text for the next turn. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { + const journal = sessions.get(sessionId)?.journal + if (journal) { + closeStructuredAgentSessionHandovers(journal) + } // Abort outside serialization so hung startup cannot block close. host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts index 7ccbd6d5497..bcd454e42a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-current-work.ts @@ -17,7 +17,11 @@ import type { StructuredAgentSessionWorkScope } from '../../../shared/structured import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { structuredAgentSessionHandingOver } from './structured-agent-session-handing-over' +import { + continuingStructuredAgentSessionInput, + finalizingStructuredAgentSessionInput, + structuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' export type StructuredAgentSessionCurrentWorkJournal = Pick< AgentSessionJournal, @@ -151,9 +155,12 @@ export class StructuredAgentSessionCurrentWork { private unanswered(submission: AgentJournalSubmission): boolean { return ( - !isQueuedAgentJournalSubmission(submission) && - this.liveFence !== null && - isUnansweredStructuredAgentSessionDispatch(submission, this.liveFence) + continuingStructuredAgentSessionInput(this.journal, submission.clientMessageId) || + (submission.dispatchState === 'pending' && + finalizingStructuredAgentSessionInput(this.journal, submission.clientMessageId)) || + (!isQueuedAgentJournalSubmission(submission) && + this.liveFence !== null && + isUnansweredStructuredAgentSessionDispatch(submission, this.liveFence)) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 0ef9c163052..37e9725748a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -73,9 +73,6 @@ export function planStructuredAgentSessionDeadGeneration( ): { mutations: JournalLifecycleMutationInput[]; dispatches: ResolveDispatchInput[] } { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal, input.exit) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork - if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { - return { mutations: [], dispatches: [] } - } // Leave queued sends to delivery. Failed-start handoffs never ran; ready-child handoffs remain uncertain. const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) @@ -84,6 +81,9 @@ export function planStructuredAgentSessionDeadGeneration( const unrun = startupFailure ?? (closed && agentSessionFailureWords(closed, { surface: 'rejection' })) const withdrawn = unrun ? [] : codexUnopenedSendResolutions(input.journal, input.fence) + if (!showUnexpectedExitOutcome && !hasUnfinishedWork && withdrawn.length === 0) { + return { mutations: [], dispatches: [] } + } const withdrawnIds = new Set(withdrawn.map((entry) => entry.clientMessageId)) const dispatches = [ ...withdrawn, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts index 1f2b73cfd10..1d24b91e097 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop-deps.ts @@ -1,3 +1,5 @@ +import type { UnsentSendHold } from '../agent-session-journal/journal-unsent-send-hold' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -27,7 +29,9 @@ export type StructuredAgentSessionDeliveryLoopDeps = { trackStart: (start: Promise) => Promise startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim /** Retire mismatched login/permission children under serialization. */ - retireOutgrownChild: (sessionId: string) => Promise + retireOutgrownChild: (sessionId: string, continuingId?: string) => Promise + immediatePermission?: (sessionId: string) => AgentChatPermissionChoice | null + retainedWork?: (sessionId: string, continuingId: string) => boolean readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity @@ -41,6 +45,13 @@ export type StructuredAgentSessionDeliveryLoopDeps = { currentWork: (sessionId: string) => StructuredAgentSessionCurrentWork | null /** Publish row-free changes in current work. */ workChanged?: (sessionId: string) => void + holdClosedInput?: ( + sessionId: string, + clientMessageId: string, + fence: number, + cause: UnsentSendHold['cause'] + ) => Promise + settleLeftovers?: (sessionId: string, owner: () => void) => Promise readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined childWorkOpen: (sessionId: string) => boolean stopping: (sessionId: string) => boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index f762d4aa4d4..2a75de892d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -3,6 +3,7 @@ import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent- // serialization for startup/readiness/provider calls so acceptance and Stop stay responsive. // Recover earlier-process queued sends before any handover; running commands block input. +import { beginStructuredAgentSessionInputHandover } from './structured-agent-session-input-handover' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' @@ -12,10 +13,7 @@ import { recordStructuredAgentSessionStartFailure, startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' -import { - beginSubmissionHandover, - type StructuredAgentSessionPendingHandover -} from './structured-agent-session-handover' +import type { StructuredAgentSessionPendingHandover } from './structured-agent-session-handover' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' @@ -136,10 +134,7 @@ export class StructuredAgentSessionDeliveryLoop { if (handed !== 'continue') { // Track the external provider call and settlement so quit waits after abort. await this.deps.trackStart( - handed - .dispatch() - .then((resolve) => this.deps.serialize(sessionId, resolve)) - .finally(() => handed.release?.()) + handed.run((resolve) => this.deps.serialize(sessionId, resolve)) ) } } @@ -268,27 +263,13 @@ export class StructuredAgentSessionDeliveryLoop { ) { return this.stop(sessionId) } - const pending = await beginSubmissionHandover( - { - sessionId, - journal: session.journal, - fence: awaitedChild.fence, - adapter: this.deps.adapter, - agents: this.deps.agents, - failureTextContext: this.deps.failureTextContext(sessionId), - record: () => this.deps.record(sessionId), - currentWork: () => this.deps.currentWork(sessionId), - childWork: () => this.deps.readChildWork(sessionId), - childWorkOpen: () => this.deps.childWorkOpen(sessionId), - now: this.deps.now, - acquireAborts: this.deps.acquireAborts, - deliveryOrder: this.deps.deliveryOrder, - workChanged: () => this.deps.workChanged?.(sessionId), - recordRanOptions: this.deps.recordRanOptions?.(sessionId, awaitedChild.fence) - }, + const pending = await beginStructuredAgentSessionInputHandover( + this.deps, + sessionId, + session, + awaitedChild, next, - // Recheck child identity immediately before the external provider write. - () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close + () => this.disposed ) return pending ?? 'continue' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index f5c799f7bd4..db978f13109 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -68,14 +68,18 @@ export class StructuredAgentSessionEventRecovery { holdUnrunSends: async (sessionId, fence, cause) => { const journal = this.context.sessions.get(sessionId)?.journal if (journal) { - await holdUnsentSends(journal, { fence, hold: { cause }, unrun: true }) + await holdUnsentSends(journal, { + fence, + hold: { cause }, + unrun: (s) => !finalizingStructuredAgentSessionInput(journal, s.clientMessageId) + }) } }, route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) }, - abortOpen: (sessionId) => runtimeState.acquireAborts.abortOpen(sessionId) + abortBound: (sessionId) => runtimeState.acquireAborts.abortBound(sessionId) } } @@ -133,3 +137,4 @@ export class StructuredAgentSessionEventRecovery { await settleStructuredAgentSessionChildExit(this.exitContext, event) } } +import { finalizingStructuredAgentSessionInput } from './structured-agent-session-handing-over' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts index 35fcd163125..c0d469c0da3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handing-over.ts @@ -1,20 +1,89 @@ // Count pending handovers and late-settlement writes as held until child/journal state catches up. +// Nested late-settlement windows share the count without replacing the input that owns its result. +import type { UnsentSendHold } from '../agent-session-journal/journal-unsent-send-hold' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { UnopenedSendJournal } from './structured-agent-session-unopened-send-withdrawal' -type HandoverJournal = Pick +/** The chat's journal, which identifies the conversation for as long as it is held. */ +type HandoverJournal = Pick -// Late settlement can overlap its own send’s handover; retain nested counts. -const handingOver = new WeakMap>() -const NONE: ReadonlyMap = new Map() +type HandoverInput = { + owner: () => void + unadmitted: boolean + signal?: AbortSignal + pending?: () => boolean + finalization: { close: (cause: UnsentSendHold['cause']) => void; completion: Promise } +} +type Handover = { count: number; input?: HandoverInput } +const handingOver = new WeakMap>() +const NONE: ReadonlyMap = new Map() +/** Cancellation retains outcome responsibility; its exact owner may bypass doubt delegation. */ +export function finalizingStructuredAgentSessionInput( + journal: HandoverJournal, + clientMessageId: string, + except?: () => void +): boolean { + const input = handingOver.get(journal)?.get(clientMessageId)?.input + return input?.finalization !== undefined && input.owner !== except +} + +/** Close cooperates with the existing result owner without joining it under host ordering. */ +export function closeStructuredAgentSessionHandovers( + journal: HandoverJournal, + cause: UnsentSendHold['cause'] = 'chatClosed' +): Promise | undefined { + const finalizers = [...(handingOver.get(journal)?.values() ?? [])].flatMap( + (entry) => entry.input?.finalization ?? [] + ) + if (finalizers.length === 0) { + return undefined + } + for (const finalizer of finalizers) { + finalizer.close(cause) + } + return Promise.all(finalizers.map((input) => input.completion)).then(() => {}) +} + +/** The sends this journal's chat is handing over now. */ export function structuredAgentSessionHandingOver( journal: HandoverJournal -): ReadonlyMap { +): ReadonlyMap { return handingOver.get(journal) ?? NONE } -/** Hold from before pending-row write through adapter reply; republish on release even without later rows. */ +/** Volatile proof owned by the live handover; a crash or cancellation restores ordinary doubt. */ +export function continuingStructuredAgentSessionInput( + journal: HandoverJournal, + clientMessageId: string +): boolean { + const input = handingOver.get(journal)?.get(clientMessageId)?.input + return input?.unadmitted === true && input.signal?.aborted !== true && input.pending?.() === true +} + +export function markStructuredAgentSessionInputUnadmitted( + journal: Pick, + clientMessageId: string, + owner: () => void, + unadmitted: boolean, + signal?: AbortSignal +): void { + const input = handingOver.get(journal)?.get(clientMessageId)?.input + if (input?.owner === owner) { + Object.assign(input, { + unadmitted, + signal, + pending: () => + journal + .submissions() + .some((s) => s.clientMessageId === clientMessageId && s.dispatchState === 'pending') + }) + } +} + +/** 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( journal: HandoverJournal, clientMessageId: string, @@ -33,25 +102,42 @@ export async function whileStructuredAgentSessionHandingOver( export function beginStructuredAgentSessionHandover( journal: HandoverJournal, clientMessageId: string, - workChanged?: () => void + workChanged?: () => void, + finalization?: { close: (cause: UnsentSendHold['cause']) => void } ): () => void { let sends = handingOver.get(journal) if (!sends) { sends = new Map() handingOver.set(journal, sends) } - sends.set(clientMessageId, (sends.get(clientMessageId) ?? 0) + 1) + const entry: Handover = sends.get(clientMessageId) ?? { count: 0 } + entry.count += 1 + sends.set(clientMessageId, entry) let held = true - return (): void => { + const completed = Promise.withResolvers() + const release = (): void => { if (held) { held = false - const count = sends.get(clientMessageId) ?? 1 - if (count > 1) { - sends.set(clientMessageId, count - 1) - } else { + if (entry.input?.owner === release) { + delete entry.input + } + entry.count -= 1 + if (entry.count === 0) { sends.delete(clientMessageId) } - workChanged?.() + try { + workChanged?.() + } finally { + completed.resolve() + } } } + if (finalization) { + entry.input = { + owner: release, + unadmitted: false, + finalization: { ...finalization, completion: completed.promise } + } + } + return release } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 621b8deafe3..58d1a5f5064 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -1,47 +1,40 @@ // Commit pending/settlement around an external provider call so concurrent sends remain acceptable. +import type { UnsentSendHold } from '../agent-session-journal/journal-unsent-send-hold' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { StructuredAgentSessionInputNotAdmitted } from './structured-agent-session-input-admission' +import type { AgentSessionHandoverContext } from './structured-agent-session-host-types' import { agentSessionFailureFact, providerDiagnosticOf } from '../../../shared/agent-session-failure' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' -import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { isAbortSignalReason } from '../../../shared/abort-signal-reason' -import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { resolveHandover } from './structured-agent-session-operation-settlement' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { handOverStructuredAgentSessionCommand, - structuredAgentSessionHandoverOrigin, - type StructuredAgentSessionCommandHandoverContext + structuredAgentSessionHandoverOrigin } from './structured-agent-session-command-turn' -import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' import { + markStructuredAgentSessionInputUnadmitted, beginStructuredAgentSessionHandover, whileStructuredAgentSessionHandingOver } from './structured-agent-session-handing-over' -export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { - acquireAborts?: Pick - deliveryOrder?: StructuredAgentSessionDeliveryOrder - workChanged?: () => void - /** Retain executed options only when the chat has no saved choice. */ - recordRanOptions?: ReturnType -} - +/** One input operation runs provider work outside ordering and commits its result inside it. */ export type StructuredAgentSessionPendingHandover = { - dispatch: () => Promise<() => Promise> - release?: () => void + run: (commit: (resolve: () => Promise) => Promise) => Promise } /** Register abort before pending-row commit so Stop cannot fall between them. @@ -49,7 +42,8 @@ export type StructuredAgentSessionPendingHandover = { export async function beginSubmissionHandover( ctx: AgentSessionHandoverContext, submission: AgentJournalSubmission, - stillCurrent: () => boolean + stillCurrent: () => boolean, + isBound: () => boolean ): Promise { const { clientMessageId } = submission const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) @@ -73,15 +67,23 @@ export async function beginSubmissionHandover( } // Join only current turns; persist stopped-history carry before writing to the provider. const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() - const carried = carriedStoppedHistory(ctx, turnScope, submission, body) + let carried = carriedStoppedHistory(ctx, turnScope, submission, body) + let ownsChild = stillCurrent + let closing: UnsentSendHold['cause'] | undefined + let unwritten = false const release = ctx.deliveryOrder?.activateSend(ctx.sessionId, clientMessageId) // Pending rows precede the adapter’s held-send evidence. const released = beginStructuredAgentSessionHandover( ctx.journal, clientMessageId, - ctx.workChanged + ctx.workChanged, + { + close: (cause) => { + closing = cause + } + } ) - const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true, false, isBound) const queued = submission.queuedMessageId !== undefined try { await ctx.journal.resolveDispatch({ @@ -92,95 +94,130 @@ export async function beginSubmissionHandover( ...(carried ? { carries: carried.covers } : {}) }) } catch (error) { - released() - release?.() attempt?.end() + release?.() + released() throw error } + const prepareFresh = async (fence: number): Promise => { + ctx.fence = fence + carried = carriedStoppedHistory(ctx, AGENT_JOURNAL_THREAD_SCOPE, submission, body) + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + carries: carried?.covers ?? [] + }) + return carried ? withStoppedHistory(body, carried.messages) : body + } // Preserve handover time so turn-open cannot re-anchor elapsed work. const requestedAt = structuredAgentSessionHandoverOrigin(ctx.journal, submission) return { - release: () => { - released() - release?.() - }, - dispatch: async () => { - let outcome: AgentSessionDispatchOutcome | null + run: async (commit) => { try { - // Pre-call abort proves queued cards never reached the provider. - outcome = attempt?.signal.aborted - ? cancelledDispatch(queued) - : // Ended-child settlement owns the row; never write to a replaced child. - stillCurrent() - ? await dispatchSafely( - ctx, + let outcome: AgentSessionDispatchOutcome | null + try { + for (;;) { + try { + markStructuredAgentSessionInputUnadmitted( + ctx.journal, clientMessageId, - carried ? withStoppedHistory(body, carried.messages) : body, - requestedAt, - submission.options, - queued, + released, + false + ) + unwritten = attempt?.signal.aborted === true + outcome = unwritten + ? cancelledDispatch(queued) + : ownsChild() + ? await dispatchSafely( + ctx, + clientMessageId, + carried ? withStoppedHistory(body, carried.messages) : body, + requestedAt, + submission.options, + queued, + ownsChild, + prepareFresh, + attempt?.signal + ) + : null + break + } catch (error) { + if (!(error instanceof StructuredAgentSessionInputNotAdmitted)) { + throw error + } + if (error.outcome) { + unwritten = true + outcome = error.outcome + break + } + if (!ctx.continueInput) { + throw error + } + markStructuredAgentSessionInputUnadmitted( + ctx.journal, + clientMessageId, + released, + true, attempt?.signal ) - : null - } finally { - attempt?.end() - } - // The journal writer orders proof before teardown; the host lane may be closing the child. - await resolveHandover(ctx, clientMessageId, outcome) - return async () => { - if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { - await ctx.recordRanOptions?.({ - message: submission.options, - executed: outcome.ranOptions - }) + const next = await ctx.continueInput(attempt?.signal, async (fence) => { + await prepareFresh(fence) + }) + if (next.kind === 'failed') { + unwritten = true + ctx.fence = next.fence + outcome = next.outcome + break + } + ownsChild = next.isCurrent + } + } + } catch (error) { + outcome = { + state: 'unknown', + reason: error instanceof Error ? error.message : String(error) + } + } finally { + markStructuredAgentSessionInputUnadmitted(ctx.journal, clientMessageId, released, false) + attempt?.end() } + // Provider proof must precede the host tail that may be closing its child. + if (outcome?.state !== 'unknown' && (!unwritten || !closing)) { + await resolveHandover(ctx, clientMessageId, outcome) + } + await commit(async () => { + if (outcome?.state === 'unknown') { + await resolveHandover( + { ...ctx, fence: ctx.record()?.lease.runtimeFence ?? ctx.fence }, + clientMessageId, + outcome + ) + } + if (unwritten && closing) { + await (closing && ctx.holdClosedInput + ? ctx.holdClosedInput(clientMessageId, ctx.fence, closing) + : resolveHandover(ctx, clientMessageId, outcome)) + } + if (!outcome || outcome.state === 'admitted') { + await ctx.settleEndedInput?.(released) + } + if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { + await ctx.recordRanOptions?.({ + message: submission.options, + executed: outcome.ranOptions + }) + } + }) + } finally { + release?.() + released() } } } } -async function resolveHandover( - ctx: AgentSessionHandoverContext, - clientMessageId: string, - outcome: AgentSessionDispatchOutcome | null -): Promise { - if (!outcome || outcome.state === 'admitted') { - return - } - try { - await ctx.journal.resolveDispatch( - outcome.state === 'accepted' - ? { - clientMessageId, - state: 'accepted', - providerIdentity: outcome.providerIdentity, - fence: ctx.fence - } - : outcome.state === 'rejected' - ? { - clientMessageId, - state: 'rejected', - reason: outcome.reason, - rejection: outcome.rejection, - ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), - fence: ctx.fence - } - : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } - ) - } catch (error) { - // Persist uncertainty if settlement fails; next-open recovery handles any remaining pending row. - try { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'unknown', - reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, - fence: ctx.fence - }) - } catch {} - throw error - } -} - /** Adapter throws may be lost replies; never resend. Only explicit rejection proves non-delivery. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, @@ -189,8 +226,11 @@ async function dispatchSafely( requestedAt: number, executionOptions: AgentSessionExecutionOptions | undefined, queued: boolean, + isCurrent: () => boolean, + prepare: (fence: number) => Promise, signal?: AbortSignal ): Promise { + const { admitFreshInput } = ctx try { const outcome = await ctx.adapter.dispatch({ sessionId: ctx.sessionId, @@ -198,6 +238,10 @@ async function dispatchSafely( body, ...(executionOptions ? { executionOptions } : {}), ...(signal ? { signal } : {}), + ...(admitFreshInput + ? { admitFreshInput: (enqueue) => admitFreshInput(enqueue, signal, prepare) } + : {}), + isCurrent, fence: ctx.fence, requestedAt, onInputPreparation: ctx.recordRanOptions?.prepareInput, @@ -205,9 +249,12 @@ async function dispatchSafely( }) return outcome } catch (error) { + if (error instanceof StructuredAgentSessionInputNotAdmitted) { + throw error + } // This signal affects preparation only; its original reason proves no provider write. if (isAbortSignalReason(signal, error)) { - return cancelledDispatch(queued) + throw new StructuredAgentSessionInputNotAdmitted(cancelledDispatch(queued)) } if (isAgentSessionOptionRejectedError(error)) { return { @@ -223,7 +270,9 @@ async function dispatchSafely( } /** Non-delivery proof applies only to queued handoffs; direct sends still owe stopped-history handling. */ -function cancelledDispatch(neverReachedAgent: boolean): AgentSessionDispatchOutcome { +function cancelledDispatch( + neverReachedAgent: boolean +): Extract { return { state: 'rejected', ...agentSessionFailureWords( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 02dfbeb9184..52504805018 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -27,10 +27,18 @@ import type { import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' -import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import { + retireOutgrownStructuredAgentSessionChild, + structuredAgentSessionChildOwesWork +} from './structured-agent-session-outgrown-child' +import { + structuredAgentSessionLaunchPermission, + structuredAgentSessionInputCompilerPermission, + structuredAgentSessionImmediatePermission +} from './structured-agent-session-launch-permission' import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { settleStructuredAgentSessionLeftovers } from './structured-agent-session-leftover-settlement' import { createStructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' export type StructuredAgentSessionConversationDelivery = { @@ -76,6 +84,19 @@ export function createStructuredAgentSessionConversationDelivery(input: { > }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input + const retainedWork = (sessionId: string, continuingId?: string) => ({ + currentWork: () => + hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), + childWork: () => input.clientDelivery.readChildWork(sessionId), + childWorkOpen: () => input.clientDelivery.childWorkOpen(sessionId), + hasOpenDispatch: () => { + const record = deps.store.getRecord(sessionId) + return record !== null && deps.hasOpenDispatch?.(record) === true + }, + providerHoldsDispatch: () => + deps.adapter.holdsDispatch?.(sessionId) === true || + [...(sessions.get(sessionId)?.child?.heldSends?.() ?? [])].some((id) => id !== continuingId) + }) const loop = new StructuredAgentSessionDeliveryLoop({ sessions, adapter: deps.adapter, @@ -83,31 +104,40 @@ export function createStructuredAgentSessionConversationDelivery(input: { serialize: input.serialize, trackStart: input.trackStart, startProviderChild: input.startProviderChild, - retireOutgrownChild: async (sessionId) => { + retireOutgrownChild: async (sessionId, continuingId) => { // Defer retirement while personal Stop holds handover; its next step decides. const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) await retireOutgrownStructuredAgentSessionChild(sessionId, session, { - work: { - currentWork: () => - hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), - childWork: () => input.clientDelivery.readChildWork(sessionId), - childWorkOpen: () => input.clientDelivery.childWorkOpen(sessionId), - hasOpenDispatch: () => { - const record = deps.store.getRecord(sessionId) - return record !== null && deps.hasOpenDispatch?.(record) === true - }, - providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true - }, + work: retainedWork(sessionId, continuingId), launchPermission: async () => { const record = deps.store.getRecord(sessionId) - return record ? structuredAgentSessionLaunchPermission(deps, record) : null + return ( + structuredAgentSessionInputCompilerPermission(session?.journal, continuingId) ?? + (record ? structuredAgentSessionLaunchPermission(deps, record) : null) + ) }, + permissionRequiresAcquisition: (mode) => + continuingId + ? true + : (deps.adapter.permissionModeRequiresAcquisition?.(sessionId, mode) ?? + mode !== session?.child?.permissionMode), stopAgent: input.stopOutgrownAgent, logger: deps.logger }) }, + immediatePermission: (sessionId) => { + const record = deps.store.getRecord(sessionId) + return record ? structuredAgentSessionImmediatePermission(deps, record) : null + }, + retainedWork: (sessionId, id) => { + const session = sessions.get(sessionId) + return ( + !!session && + structuredAgentSessionChildOwesWork(session.journal, retainedWork(sessionId, id)) + ) + }, readiness: input.readiness, startFlights: input.startFlights, deliveryOrder: input.deliveryOrder, @@ -130,6 +160,36 @@ export function createStructuredAgentSessionConversationDelivery(input: { ), logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), + holdClosedInput: async (sessionId, id, fence, cause) => { + const session = sessions.get(sessionId) + if ( + session && + !(await holdClosedStructuredAgentSessionSends( + deps, + sessionId, + session.journal, + { mark: 'settled', unrun: (s) => s.clientMessageId === id && s.fence === fence }, + cause + )) + ) { + throw new Error('settling an unwritten closed input failed') + } + }, + settleLeftovers: async (sessionId, owner) => { + const session = sessions.get(sessionId) + if (session) { + const result = await settleStructuredAgentSessionLeftovers({ + store: deps.store, + sessionId, + journal: session.journal, + ended: session.lastEndedChild, + finalizingOwner: owner + }) + if (!result.ok) { + throw result.error + } + } + }, currentWork: (sessionId) => hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId), readChildWork: input.clientDelivery.readChildWork, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 08a6bcb960e..1f171acbbf4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -1,9 +1,17 @@ // Stop releases the provider child/lease while keeping conversation state; close drops its cached fold. // Call under existing serialization because session queues are nonreentrant. +import { + closeStructuredAgentSessionHandovers, + finalizingStructuredAgentSessionInput, + structuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' +import { + holdUnsentSends, + type UnsentSendHold +} from '../agent-session-journal/journal-unsent-send-hold' import { snapshotBeforeStructuredAgentSessionStop, StructuredAgentSessionEvictionError @@ -61,13 +69,19 @@ export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], - close: { mark: 'always' | 'settled'; which?: (submission: AgentJournalSubmission) => boolean } + close: { + mark: 'always' | 'settled' + which?: (submission: AgentJournalSubmission) => boolean + unrun?: (submission: AgentJournalSubmission) => boolean + }, + cause: UnsentSendHold['cause'] = 'chatClosed' ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) const { which } = close const settled = await holdUnsentSends(journal, { fence, - hold: { cause: 'chatClosed', ...(which ? { which } : {}) } + ...(close.unrun ? { unrun: close.unrun } : {}), + hold: { cause, ...(which ? { which } : {}) } }).then( (newest) => ({ ok: true, newest }), (error: unknown) => { @@ -195,6 +209,9 @@ export function structuredAgentSessionConversationClosable( ): boolean { return ( session.child === null && + ![...structuredAgentSessionHandingOver(session.journal).keys()].some((id) => + finalizingStructuredAgentSessionInput(session.journal, id) + ) && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && session.journal.pendingSubmissions().length === 0 && !(atRest && session.journal.queuedMessages.awaitReopenMark()) @@ -219,6 +236,49 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( return true } +/** Close stops resources first; the exact outstanding result retires its journal afterward. */ +export function beginFinalizedStructuredAgentSessionClose( + context: { + sessions: ReadonlyMap + serialize: (sessionId: string, task: () => Promise) => Promise + closeConversation: (sessionId: string) => Promise + deps: () => Pick + }, + sessionId: string, + session: StructuredAgentSessionHostSession | undefined +): (() => void) | undefined { + if (!session) { + return undefined + } + const acceptedBy = session.journal.cursor().sequence + const completion = closeStructuredAgentSessionHandovers(session.journal) + if (!completion) { + return undefined + } + return () => { + const ended = session.lastEndedChild + void completion + .then(() => + context.serialize(sessionId, async () => { + if ( + context.sessions.get(sessionId) === session && + !session.child && + session.lastEndedChild === ended && + !session.journal.submissions().some((s) => (s.acceptedSequence ?? 0) > acceptedBy) + ) { + await context.closeConversation(sessionId) + } + }) + ) + .catch((error: unknown) => + context.deps().logger.warn('closing a finalized chat handle failed', { + scope: 'handover-close', + sessionId, + error + }) + ) + } +} export async function evictOwnedStructuredAgentSessions( context: StructuredAgentSessionLifetimeContext & { serialize: (sessionId: string, task: () => Promise) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index da80a840bc4..e0c0d552120 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -1,6 +1,7 @@ // Attempt every teardown phase despite earlier failures; drain conversation writes before closing // the shared database last. Queued sends recover on next open. +import { closeStructuredAgentSessionHandovers } from './structured-agent-session-handing-over' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' @@ -148,6 +149,9 @@ export async function flushStructuredAgentSessionHost( trigger: AgentSessionResumeTrigger } ): Promise { + for (const session of context.sessions.values()) { + closeStructuredAgentSessionHandovers(session.journal, 'hostRestarted') + } const retainSessionIds = new Set() await tearDownStructuredAgentSessionHost({ phases: structuredAgentSessionHostTeardownPhases({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 16820faa6f2..4fba81a5dc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -79,3 +79,37 @@ export function hostTestDrawnRowIds( projectStructuredAgentSessionMessages(snapshot.items, optimistic, snapshot.submissions) ).map(({ id }) => id) } + +export const CODEX_INPUT_FINALIZATION_ADMISSION_CASES = [ + ['queue-stop', 0], + ['queue-close', 0], + ['queue-quit', 0], + ['pending-stop', 0], + ['pending-close', 0], + ['pending-close-cards', 0], + ['pending-quit', 0], + ['pending-exit', 0], + ['queue-sleep', 0], + ['pending-sleep', 0], + ['pending-close-then-quit', 0], + ['pending-quit-then-close', 0], + ['pending-close-cards', 10], + ['pending-close-cards', 100] +] as const + +export const CODEX_INPUT_FINALIZATION_OWNER_OUTCOMES = [ + 'null', + 'live', + 'admitted', + 'terminal', + 'normal', + 'fallback', + 'failed', + 'close-normal', + 'close-failed', + 'safe-close', + 'new-session', + 'new-live-child', + 'new-ended-child', + 'new-send' +] as const diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 14ad4c3ff14..4e194d9cb42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,5 +1,20 @@ +import type { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import type { + OpenedStructuredAgentSessionConversation, + StructuredAgentSessionConversationOpenOptions +} from './structured-agent-session-conversation-open' +import type { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { UnsentSendHold } from '../agent-session-journal/journal-unsent-send-hold' import type { AgentSessionStopOutcome } from './structured-agent-session-adapter-stop' import type { StructuredAgentSessionRequestFailure } from './structured-agent-session-request-failures' +import type { StructuredAgentSessionCommandHandoverContext } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' +import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' +import type { + StructuredAgentSessionAdmitHostInput, + StructuredAgentSessionContinueInput +} from './structured-agent-session-input-admission' import type { StructuredAgentSessionStatusObserverOptions } from './structured-agent-session-status-observation' import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' @@ -185,3 +200,40 @@ export type StructuredAgentSessionConversationNaming = { write: AgentSessionUserNameWrite ) => Promise<{ name: string | null; applied: boolean } | null> } + +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { + admitFreshInput?: StructuredAgentSessionAdmitHostInput + continueInput?: StructuredAgentSessionContinueInput + acquireAborts?: Pick + deliveryOrder?: StructuredAgentSessionDeliveryOrder + workChanged?: () => void + holdClosedInput?: ( + clientMessageId: string, + fence: number, + cause: UnsentSendHold['cause'] + ) => Promise + settleEndedInput?: (owner: () => void) => Promise + /** A message the agent took: a chat keeps, where it has none saved, the options it ran. */ + recordRanOptions?: ReturnType +} + +export type StructuredAgentSessionConversationLifetime = ReturnType< + typeof createStructuredAgentSessionConversationLifetime +> + +export type StructuredAgentSessionConversationDelivery = { + loop: StructuredAgentSessionDeliveryLoop + open: ( + sessionId: string, + options?: StructuredAgentSessionConversationOpenOptions + ) => Promise + /** Queue commit wakes under serialization so they cannot race a loop’s stopping decision. */ + afterCommit: (sessionId: string, journal: AgentSessionJournal) => void + /** Settle edges affect status/steer holds without rows or renewed idle activity. */ + afterSettleEdge: (sessionId: string, journal: AgentSessionJournal) => void + dispose: () => void + adoptOpened: ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ) => Promise +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts new file mode 100644 index 00000000000..86c9cf17c2e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-input-admission.ts @@ -0,0 +1,39 @@ +import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode' + +export type StructuredAgentSessionInputAdmission = + | { kind: 'prepare' } + | { kind: 'acquire' } + | { kind: 'sent'; response: Promise } + +/** The synchronous writer runs under host ordering; its response is awaited outside it. */ +export type StructuredAgentSessionAdmitFreshInput = ( + enqueue: ( + mode: AgentChatPermissionChoice, + inheritRunning: boolean, + body?: AgentJournalMessageItem + ) => StructuredAgentSessionInputAdmission, + signal?: AbortSignal +) => Promise + +export type StructuredAgentSessionAdmitHostInput = ( + enqueue: Parameters[0], + signal: AbortSignal | undefined, + prepare: (fence: number) => Promise +) => ReturnType + +export type StructuredAgentSessionContinueInput = ( + signal: AbortSignal | undefined, + rebind: (fence: number) => Promise +) => Promise< + | { kind: 'ready'; isCurrent: () => boolean } + | { kind: 'failed'; fence: number; outcome: AgentSessionDispatchOutcome } +> + +/** Host-local control, never a provider failure or a replay of a possibly written request. */ +export class StructuredAgentSessionInputNotAdmitted extends Error { + constructor(readonly outcome?: Extract) { + super('input was not admitted') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts new file mode 100644 index 00000000000..5d29f54eeae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-input-handover.ts @@ -0,0 +1,261 @@ +import { + StructuredAgentSessionInputNotAdmitted, + type StructuredAgentSessionInputAdmission +} from './structured-agent-session-input-admission' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { continuingStructuredAgentSessionInput } from './structured-agent-session-handing-over' +import type { StructuredAgentSessionDeliveryLoopDeps } from './structured-agent-session-delivery-loop' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' +import { + beginSubmissionHandover, + type StructuredAgentSessionPendingHandover +} from './structured-agent-session-handover' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import { + providerStartupFailureFact, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { + refusedStructuredAgentSessionStart, + structuredAgentSessionStartFailureRow, + 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' + +type StartFailure = NonNullable> + +/** The scheduler's selected input keeps one handover while its exact child binding changes. */ +export function beginStructuredAgentSessionInputHandover( + deps: StructuredAgentSessionDeliveryLoopDeps, + sessionId: string, + session: StructuredAgentSessionHostSession, + awaitedChild: StructuredAgentSessionProviderChild, + next: AgentJournalSubmission, + isDisposed: () => boolean +): Promise { + let binding = awaitedChild + 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') + const isBound = () => deps.sessions.get(sessionId) === session && session.child === binding + const isCurrent = () => isBound() && !binding.close + const validate = (signal?: AbortSignal) => { + const work = deps.currentWork(sessionId) + if ( + signal?.aborted || + isDisposed() || + !isCurrent() || + binding.phase !== 'ready' || + deps.stopping(sessionId) || + !ownsInput() || + (work && structuredAgentSessionCommandRunning(work)) + ) { + const ended = session.lastEndedChild + throw new StructuredAgentSessionInputNotAdmitted({ + state: 'rejected', + ...structuredAgentSessionStartFailure( + (ended && sameProviderChild(ended, binding) + ? structuredAgentSessionEndedChildFailure(ended) + : null) ?? { + failure: agentSessionFailureFact('cancelled', { neverReachedAgent: true }) + }, + deps.failureTextContext(sessionId) + ) + }) + } + } + return beginSubmissionHandover( + { + sessionId, + journal: session.journal, + fence: awaitedChild.fence, + adapter: deps.adapter, + agents: deps.agents, + failureTextContext: deps.failureTextContext(sessionId), + record: () => deps.record(sessionId), + + admitFreshInput: async (enqueue, signal, prepare) => { + let admission: StructuredAgentSessionInputAdmission | undefined + const invoked = deps.serialize(sessionId, async () => { + validate(signal) + const body = await prepare(binding.fence) + validate(signal) + const mode = deps.immediatePermission?.(sessionId) + if (!mode) { + throw new Error('input handover has no permission choice') + } + admission = enqueue( + mode, + deps.retainedWork?.(sessionId, next.clientMessageId) === true, + body + ) + return admission + }) + try { + return await waitForPromiseWithSignal(invoked, signal) + } catch (error) { + // Enqueue is synchronous; Promise delivery must not turn an actual write into a withdrawal. + if (admission?.kind === 'sent') { + return admission + } + validate(signal) + throw error + } + }, + continueInput: async (signal, rebind) => { + const failed = async ( + cause: StartFailure['cause'], + fence: number, + startKey = next.clientMessageId + ) => { + const words = structuredAgentSessionStartFailure( + 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 } + } + } + try { + const claim = await waitForPromiseWithSignal( + deps.serialize(sessionId, async () => { + validate(signal) + if ( + !ownsInput() || + !session.journal + .submissions() + .some( + (s) => s.clientMessageId === next.clientMessageId && s.fence === binding.fence + ) || + !continuingStructuredAgentSessionInput(session.journal, next.clientMessageId) + ) { + throw new Error('input continuation no longer owns its submission') + } + await deps.retireOutgrownChild(sessionId, next.clientMessageId) + signal?.throwIfAborted() + if (!ownsInput() || (session.child && session.child !== binding)) { + throw new Error('input continuation lost its provider binding') + } + return deps.startProviderChild(sessionId, next.clientMessageId) + }), + signal + ) + const started = await waitForPromiseWithSignal(claim.outcome, signal) + if (!started.ok) { + return deps.serialize(sessionId, () => + failed( + started.aborted + ? { failure: agentSessionFailureFact('cancelled') } + : refusedStructuredAgentSessionStart(deps.record(sessionId), started).cause, + deps.conversationFence(sessionId) + ) + ) + } + const child = deps.sessions.get(sessionId)?.child + const rebound = await waitForPromiseWithSignal( + deps.serialize(sessionId, async () => { + signal?.throwIfAborted() + const current = deps.sessions.get(sessionId)?.child + if ( + !ownsInput() || + !current || + current !== child || + current.close || + current.phase !== 'ready' + ) { + const ended = session.lastEndedChild + return failed( + (ended && (!child || sameProviderChild(ended, child)) + ? structuredAgentSessionEndedChildFailure(ended) + : undefined) ?? { failure: agentSessionFailureFact('startFailed') }, + child?.fence ?? deps.conversationFence(sessionId), + child?.generation ?? next.clientMessageId + ) + } + binding = current + recorder = deps.recordRanOptions?.(sessionId, binding.fence) + await rebind(current.fence) + validate(signal) + return null + }), + signal + ) + if (rebound) { + return rebound + } + return { kind: 'ready', isCurrent } + } catch (error) { + if (signal?.aborted) { + return deps.serialize(sessionId, () => { + const ended = session.lastEndedChild + return failed( + (ended?.cause === 'exit' + ? structuredAgentSessionEndedChildFailure(ended) + : null) ?? { + failure: agentSessionFailureFact('cancelled') + }, + deps.conversationFence(sessionId) + ) + }) + } + return deps.serialize(sessionId, () => + failed( + { + failure: signal?.aborted + ? agentSessionFailureFact('cancelled') + : providerStartupFailureFact(error) + }, + deps.conversationFence(sessionId) + ) + ) + } + }, + + currentWork: () => deps.currentWork(sessionId), + childWork: () => deps.readChildWork(sessionId), + childWorkOpen: () => deps.childWorkOpen(sessionId), + now: deps.now, + acquireAborts: deps.acquireAborts, + deliveryOrder: deps.deliveryOrder, + workChanged: () => deps.workChanged?.(sessionId), + holdClosedInput: (id, fence, cause) => + deps.holdClosedInput?.( + sessionId, + id, + session.journal.submission(id)?.fence ?? fence, + cause + ) ?? Promise.resolve(), + settleEndedInput: async (owner) => { + if (session.lastEndedChild && sameProviderChild(session.lastEndedChild, binding)) { + await deps.settleLeftovers?.(sessionId, owner) + } + }, + get recordRanOptions() { + return recorder + } + }, + next, + // Re-read right before the provider call, which runs outside the serialize. + isCurrent, + isBound + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index e2fe2904e19..d5034f971ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -34,6 +34,12 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' +import { + closeStructuredAgentSessionHandovers, + finalizingStructuredAgentSessionInput, + structuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' + const CALLER = { callerKey: 'client-1' } let root: string @@ -202,6 +208,84 @@ describe('settling a send the provider proves it received after the ack window', expect(dispatch).toHaveBeenCalledTimes(1) }) + it.each(['N-first', 'O-first'] as const)( + 'retains the original result owner with %s', + async (order) => { + const original = Promise.withResolvers() + const nested = Promise.withResolvers() + const entered = Promise.withResolvers() + dispatch.mockImplementationOnce(() => original.promise) + const held = journal() + const acceptLate = held.acceptLate.bind(held) + const acceptance = vi.spyOn(held, 'acceptLate').mockImplementation(async (input) => { + entered.resolve() + await nested.promise + return acceptLate(input) + }) + const params = sendParams('overlapping result and receipt') + const id = params.envelope.clientOperationId + const sending = host.send(CALLER, params) + let settlement: Promise | undefined + let observed: Promise | undefined + let completed = 0 + try { + await handedOver(id) + settlement = host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'overlap-echo' } + }) + void settlement.catch(() => undefined) + await entered.promise + const completion = closeStructuredAgentSessionHandovers(held) + expect(completion).toBeDefined() + observed = completion?.then(() => { + completed += 1 + }) + expect(finalizingStructuredAgentSessionInput(held, id)).toBe(true) + expect(structuredAgentSessionHandingOver(held).has(id)).toBe(true) + + if (order === 'N-first') { + nested.resolve() + await settlement + expect(finalizingStructuredAgentSessionInput(held, id)).toBe(true) + expect(structuredAgentSessionHandingOver(held).has(id)).toBe(true) + await new Promise((resolve) => setImmediate(resolve)) + expect(completed).toBe(0) + original.resolve({ state: 'unknown', reason: 'ack timeout' }) + await observed + } else { + original.resolve({ state: 'admitted' }) + await observed + expect(finalizingStructuredAgentSessionInput(held, id)).toBe(false) + expect(completed).toBe(1) + expect(structuredAgentSessionHandingOver(held).has(id)).toBe(true) + expect(closeStructuredAgentSessionHandovers(held)).toBeUndefined() + nested.resolve() + await settlement + } + + await expect(sending).resolves.toMatchObject({ ok: true }) + await observed + expect(completed).toBe(1) + expect(finalizingStructuredAgentSessionInput(held, id)).toBe(false) + expect(structuredAgentSessionHandingOver(held).has(id)).toBe(false) + expect(held.submission(id)).toMatchObject({ + clientMessageId: id, + dispatchState: 'accepted', + providerItemId: `claude:${THREAD}:overlap-echo` + }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(acceptance).toHaveBeenCalledTimes(1) + } finally { + original.resolve({ state: 'unknown', reason: 'cleanup' }) + nested.resolve() + await Promise.allSettled([sending, settlement, observed]) + acceptance.mockRestore() + } + } + ) + it('persists an echo received while the provider is closing', async () => { dispatch.mockResolvedValueOnce({ state: 'unknown', reason: 'ack timeout' }) const params = sendParams('received just before shutdown') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts index b3f858d82ed..b9678dff8c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.ts @@ -1,6 +1,10 @@ +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 type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentChatPermissionModes, + savedChatPermissionMode, chatModelSupportsAutoMode, chatPermissionModeNeedsModelSupport, effectiveChatPermissionMode, @@ -44,3 +48,39 @@ export async function structuredAgentSessionLaunchPermission( (await listedAutoModeSupport(deps.modelCatalog, record)) return effectiveChatPermissionMode(support, intent, autoSupported) } + +/** A synchronous admission read for providers whose modes need no model-catalog evidence. */ +export function structuredAgentSessionImmediatePermission( + deps: Pick, + record: AgentSessionRecord +): AgentChatPermissionChoice | null { + const support = agentChatPermissionModes(record.provider) + return support && deps.agents.definition(record.provider) + ? savedChatPermissionMode(support, record.permissionMode) + : null +} + +/** Only the exact live, positively unwritten input pins its acquisition compiler to Auto. */ +export function structuredAgentSessionInputCompilerPermission( + journal: Parameters[0] | undefined, + startedFor?: string +): 'auto' | undefined { + return journal && startedFor && continuingStructuredAgentSessionInput(journal, startedFor) + ? 'auto' + : undefined +} + +/** Read the live continuation at invocation, before ordinary asynchronous launch resolution. */ +export function structuredAgentSessionAttachPermission( + context: Pick, + options: { startedFor?: string }, + lock: Pick +): (record: AgentSessionRecord) => Promise { + return async (record) => + structuredAgentSessionInputCompilerPermission( + context.sessions.get(record.sessionId)?.journal, + options.startedFor + ) ?? + (await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ?? + undefined +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts index a1ae387a0db..8be3a993c18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-settlement.ts @@ -1,3 +1,7 @@ +import { + continuingStructuredAgentSessionInput, + finalizingStructuredAgentSessionInput +} from './structured-agent-session-handing-over' // Settle ended generations atomically at the current fence. Death/replacement proof interrupts; // absent proof marks unverifiable, revisable by later proof. Failed settlement holds no live work. @@ -34,6 +38,7 @@ export type StructuredAgentSessionLeftoverSettlementInput = { exit?: StructuredAgentSessionExitSettlement /** Observed root exit ends the generation even if lease release failed. */ ended?: StructuredAgentSessionWorkEvidence['ended'] + finalizingOwner?: () => void } export type StructuredAgentSessionLeftoverSettlement = @@ -90,7 +95,17 @@ export async function settleStructuredAgentSessionLeftovers( const dispatches = [ ...exited.dispatches, ...stale.dispatches.filter((entry) => !settledByExit.has(entry.clientMessageId)) - ] + ].filter( + (entry) => + !continuingStructuredAgentSessionInput(journal, entry.clientMessageId) && + // Exit proof can settle a send now; generic doubt waits for its own result. + (entry.state === 'rejected' || + !finalizingStructuredAgentSessionInput( + journal, + entry.clientMessageId, + input.finalizingOwner + )) + ) planned = mutations.length + dispatches.length return { mutations, dispatches } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 46ffb44455e..f07d5d13cdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -1,3 +1,6 @@ +import type { AgentSessionHandoverContext } from './structured-agent-session-host-types' +import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' +import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -118,3 +121,50 @@ export function observedReceipt( } return observed } + +export async function resolveHandover( + ctx: AgentSessionHandoverContext, + clientMessageId: string, + outcome: AgentSessionDispatchOutcome | null +): Promise { + if (!outcome || outcome.state === 'admitted') { + return + } + try { + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), + fence: ctx.fence + } + : { + clientMessageId, + state: 'unknown', + reason: outcome.reason, + fence: ctx.fence + } + ) + } catch (error) { + // Persist uncertainty if settlement fails; next-open recovery handles any remaining pending row. + try { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'unknown', + reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, + fence: ctx.fence + }) + } catch {} + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 29445164e89..ccf55aa9819 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -188,12 +188,14 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { input: Parameters[0], work: Partial = {}, stopAgent = vi.fn(async () => undefined), - launchPermission?: () => Promise + launchPermission?: () => Promise, + permissionRequiresAcquisition?: (mode: AgentChatPermissionChoice) => boolean ) => { const warn = vi.fn() await retireOutgrownStructuredAgentSessionChild('session', conversation(input), { work: { ...idle, ...work }, ...(launchPermission ? { launchPermission } : {}), + ...(permissionRequiresAcquisition ? { permissionRequiresAcquisition } : {}), stopAgent, logger: { warn, error: vi.fn() } }) @@ -260,6 +262,19 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { expect((await retire(askChild, {}, undefined, now('ask'))).stopAgent).not.toHaveBeenCalled() }) + it('keeps a child that can apply policy per turn, and acquires unknown Auto only when idle', async () => { + expect( + (await retire(askChild, {}, undefined, now('full-access'), () => false)).stopAgent + ).not.toHaveBeenCalled() + expect( + (await retire(askChild, { childWorkOpen: () => true }, undefined, now('auto'), () => true)) + .stopAgent + ).not.toHaveBeenCalled() + expect( + (await retire(askChild, {}, undefined, now('auto'), () => true)).stopAgent + ).toHaveBeenCalledWith('session') + }) + it('is kept while it owes work, as a signed-out one is', async () => { for (const kept of [ await retire(askChild, owing('turn-1', false), undefined, now('auto')), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index aa2729d702a..76326c3a253 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,4 +1,4 @@ -// Refresh login/permission mode by replacing outgrown children before sending; agents read them only at startup. +// Replace an outgrown child only when its provider needs acquisition to apply the current choice. import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' @@ -68,13 +68,17 @@ function owesWakeUp( /** Compare the actual launch mode so replacement cannot loop. */ async function launchedWithOtherPermissionMode( child: OutgrownChildReading['child'], - current: (() => Promise) | undefined + current: (() => Promise) | undefined, + requiresAcquisition: ((mode: AgentChatPermissionChoice) => boolean) | undefined ): Promise { if (!child || child.phase === 'starting' || child.close || !child.permissionMode || !current) { return false } const mode = await current() - return mode !== null && mode !== child.permissionMode + return ( + mode !== null && + (requiresAcquisition ? requiresAcquisition(mode) : mode !== child.permissionMode) + ) } /** Retire under serialization only when no work is owed; failed Stop leaves a close for startup to join. */ @@ -87,6 +91,7 @@ export async function retireOutgrownStructuredAgentSessionChild( childWork: () => readonly AgentChildWorkView[] | undefined } launchPermission?: () => Promise + permissionRequiresAcquisition?: (mode: AgentChatPermissionChoice) => boolean stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } @@ -95,10 +100,13 @@ export async function retireOutgrownStructuredAgentSessionChild( !session || !( structuredAgentSessionChildReportedSignedOut(session) || - (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) + (await launchedWithOtherPermissionMode( + session.child, + deps.launchPermission, + deps.permissionRequiresAcquisition + )) ) || - structuredAgentSessionChildHasOpenWork(deps.work) || - owesWakeUp(session.journal, deps.work.childWork()) + structuredAgentSessionChildOwesWork(session.journal, deps.work) ) { return } @@ -110,3 +118,12 @@ export async function retireOutgrownStructuredAgentSessionChild( }) ) } + +export function structuredAgentSessionChildOwesWork( + journal: Pick, + work: StructuredAgentSessionChildWorkReads & { + childWork: () => readonly AgentChildWorkView[] | undefined + } +): boolean { + return structuredAgentSessionChildHasOpenWork(work) || owesWakeUp(journal, work.childWork()) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index a0503d17be1..021844e711f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -1,3 +1,4 @@ +import { sameProviderChild } from './structured-agent-session-provider-child' import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { @@ -21,7 +22,10 @@ export async function openStructuredAgentSessionProviderChild( if (!context.runtimeState.startupAttempts.onClock(sessionId, child)) { return } - const attempt = context.runtimeState.acquireAborts.begin(sessionId, false, false, true) + const attempt = context.runtimeState.acquireAborts.begin(sessionId, false, false, () => { + const current = context.sessions.get(sessionId)?.child + return current !== undefined && current !== null && sameProviderChild(current, child) + }) const openedAt = context.now() try { attempt.signal.throwIfAborted() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts index 37b1a5956f9..8323dfad49f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ran-options.ts @@ -41,7 +41,7 @@ export function structuredAgentSessionChildRan( return childRuns.get(child)?.reported } -/** The current child's descriptive report; never a saved or applied permission. */ +/** The current child's startup report or applied evidence; never saved intent or send authority. */ export function structuredAgentSessionRuntimePermission( child: StructuredAgentSessionProviderChild | undefined ): { fence: number; mode: string | null } | null { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts index dda212ad8e5..2d592878ad4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-attempts.ts @@ -1,3 +1,4 @@ +import { continuingStructuredAgentSessionInput } from './structured-agent-session-handing-over' import { randomUUID } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' @@ -200,7 +201,10 @@ export function structuredAgentSessionStartStillQueued( session?.journal .submissions() .some( - (entry) => entry.clientMessageId === startedFor && isQueuedAgentJournalSubmission(entry) + (entry) => + entry.clientMessageId === startedFor && + (isQueuedAgentJournalSubmission(entry) || + continuingStructuredAgentSessionInput(session.journal, startedFor)) ) === true ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history-selection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history-selection.ts index 7ca01e7d12b..01d191b8f08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history-selection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history-selection.ts @@ -38,7 +38,9 @@ export function carriedStoppedHistory( return selectStoppedHistory( agentSessionStoppedHistoryOwed({ submissions: ctx.journal.submissions(), - carried: ctx.journal.stoppedHistoryCarried(), + carried: new Map( + [...ctx.journal.stoppedHistoryCarried()].filter(([id]) => id !== submission.clientMessageId) + ), clearSequence: ctx.journal.context.floor()?.sequence ?? 0, body: (id) => ctx.journal.itemBody(agentJournalSubmissionKey(id)) }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts index 782be0853d9..1f1e267f17a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -1,3 +1,7 @@ +import { + continuingStructuredAgentSessionInput, + finalizingStructuredAgentSessionInput +} from './structured-agent-session-handing-over' import { providerTimelineExecutionItems } from '../agent-session-timeline/provider-timeline-history' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -57,6 +61,7 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( .some( (send) => heldSends?.has(send.clientMessageId) && + !finalizingStructuredAgentSessionInput(journal, send.clientMessageId) && send.fence === exit?.ownerFence && !isQueuedAgentJournalSubmission(send) && (send.dispatchState === 'pending' || send.dispatchState === 'unknown') @@ -121,9 +126,11 @@ function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { ? submissions.some( (submission) => // Unhanded queued messages are not work in progress. - (submission.dispatchState === 'pending' && + !continuingStructuredAgentSessionInput(journal, submission.clientMessageId) && + !finalizingStructuredAgentSessionInput(journal, submission.clientMessageId) && + ((submission.dispatchState === 'pending' && !(submission.handoverRecorded && submission.handedOverAt === undefined)) || - (submission.dispatchState === 'unknown' && submission.recovered !== true) + (submission.dispatchState === 'unknown' && submission.recovered !== true)) ) : (journal.pendingSubmissions?.().length ?? 0) > 0 } diff --git a/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap index 88fcd2639dd..7cdc67bd47f 100644 --- a/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap +++ b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap @@ -1159,7 +1159,7 @@ exports[`native-chat launch parity > codex floating fresh 1`] = ` "permissionPolicy": { "approvalPolicy": "on-request", "approvalsReviewer": "user", - "sandbox": "workspace-write", + "sandbox": "read-only", }, }, "pinnedLaunchDirectories": [ @@ -1233,7 +1233,7 @@ exports[`native-chat launch parity > codex floating resumed 1`] = ` "permissionPolicy": { "approvalPolicy": "on-request", "approvalsReviewer": "user", - "sandbox": "workspace-write", + "sandbox": "read-only", }, }, "pinnedLaunchDirectories": [], @@ -1305,7 +1305,7 @@ exports[`native-chat launch parity > codex worktree fresh 1`] = ` "permissionPolicy": { "approvalPolicy": "on-request", "approvalsReviewer": "user", - "sandbox": "workspace-write", + "sandbox": "read-only", }, }, "pinnedLaunchDirectories": [], @@ -1448,7 +1448,7 @@ exports[`native-chat launch parity > codex worktree fresh worker 1`] = ` "permissionPolicy": { "approvalPolicy": "on-request", "approvalsReviewer": "user", - "sandbox": "workspace-write", + "sandbox": "read-only", }, }, "pinnedLaunchDirectories": [], @@ -1520,7 +1520,7 @@ exports[`native-chat launch parity > codex worktree resumed 1`] = ` "permissionPolicy": { "approvalPolicy": "on-request", "approvalsReviewer": "user", - "sandbox": "workspace-write", + "sandbox": "read-only", }, }, "pinnedLaunchDirectories": [], diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 569caec180b..49777fa0422 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -90,7 +90,7 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA logger: deps.logger, onEvent: (event) => { // Every exit, expected or not: the host ends that child's record. - if (event.type === 'ended' && 'cause' in event) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { context.deliverLifecycle(event) } } diff --git a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx index c79a7e5380d..32764995f68 100644 --- a/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPermissionModePicker.test.tsx @@ -140,7 +140,7 @@ describe('NativeChatPermissionModePicker', () => { ) expect(screen.queryByText('Accept edits')).toBeNull() expect(option('ask').textContent).toContain( - 'Edits files and runs commands in this workspace; asks before anything else' + "Asks before edits or commands it isn't already allowed to make" ) expect(option('full-access').textContent).toContain( 'Never asks; can edit any file and use the network' diff --git a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts index 44b491dfe3b..fdc6c77cde2 100644 --- a/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts +++ b/src/renderer/src/components/native-chat/native-chat-permission-mode-labels.ts @@ -41,12 +41,6 @@ export function nativeChatPermissionModeDescription( ): string { switch (mode) { case 'ask': - if (agent === 'codex') { - return translate( - 'components.native-chat.composer.permissionAskCodexDescription', - 'Edits files and runs commands in this workspace; asks before anything else' - ) - } if (agent !== 'claude') { return translate( 'components.native-chat.composer.permissionAskSharedDescription', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e8c640b9b27..0cb04779b4d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19376,7 +19376,6 @@ "permissions": "Permissions", "permissionAsk": "Ask for approval", "permissionAskDescription": "Asks before edits and commands your settings don't allow", - "permissionAskCodexDescription": "Edits files and runs commands in this workspace; asks before anything else", "permissionAskSharedDescription": "Asks before edits or commands it isn't already allowed to make", "permissionAcceptEdits": "Accept edits", "permissionAcceptEditsDescription": "Approves file edits and file commands; asks for the rest", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4e279091669..e582627df2c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18909,7 +18909,6 @@ "permissions": "Permisos", "permissionAsk": "Pedir aprobación", "permissionAskDescription": "Pregunta antes de ediciones y comandos que tu configuración no permite", - "permissionAskCodexDescription": "Edita archivos y ejecuta comandos en este espacio de trabajo; pregunta antes de cualquier otra cosa", "permissionAskSharedDescription": "Pregunta antes de ediciones o comandos que aún no tiene permitidos", "permissionAcceptEdits": "Aceptar ediciones", "permissionAcceptEditsDescription": "Aprueba ediciones y comandos de archivos; pregunta por lo demás", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index acc1d54c8fd..8a849ce3e65 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19049,7 +19049,6 @@ "permissions": "Autorisations", "permissionAsk": "Demander l’approbation", "permissionAskDescription": "Demande avant les modifications et commandes que vos réglages n'autorisent pas", - "permissionAskCodexDescription": "Modifie des fichiers et exécute des commandes dans cet espace de travail ; demande avant toute autre action", "permissionAskSharedDescription": "Demande avant les modifications ou commandes qui ne sont pas déjà autorisées", "permissionAcceptEdits": "Accepter les modifications", "permissionAcceptEditsDescription": "Approuve les modifications et commandes de fichiers ; demande pour le reste", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index defb0520612..0f0f7d80829 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18938,7 +18938,6 @@ "permissions": "権限", "permissionAsk": "承認を求める", "permissionAskDescription": "設定で許可されていない編集やコマンドの前に確認します", - "permissionAskCodexDescription": "このワークスペース内でファイルを編集しコマンドを実行します。それ以外の操作の前に確認します", "permissionAskSharedDescription": "まだ許可されていない編集やコマンドの前に確認します", "permissionAcceptEdits": "編集を許可", "permissionAcceptEditsDescription": "ファイルの編集とファイル操作コマンドを承認し、それ以外は確認します", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fb522831377..5750552704e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18938,7 +18938,6 @@ "permissions": "권한", "permissionAsk": "승인 요청", "permissionAskDescription": "설정에서 허용하지 않은 편집과 명령 전에 묻습니다", - "permissionAskCodexDescription": "이 작업 공간에서 파일을 편집하고 명령을 실행하며, 그 외 작업 전에는 묻습니다", "permissionAskSharedDescription": "아직 허용되지 않은 편집이나 명령 전에 묻습니다", "permissionAcceptEdits": "편집 허용", "permissionAcceptEditsDescription": "파일 편집과 파일 명령은 승인하고, 나머지는 묻습니다", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index fc2233cd7a9..f1878c8d1ce 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19324,7 +19324,6 @@ "permissions": "Permissões", "permissionAsk": "Pedir aprovação", "permissionAskDescription": "Pede aprovação antes de edições e comandos que suas configurações não permitem", - "permissionAskCodexDescription": "Edita arquivos e executa comandos neste espaço de trabalho; pede aprovação para outras ações", "permissionAskSharedDescription": "Pede aprovação antes de edições ou comandos que ainda não tem permissão para realizar", "permissionAcceptEdits": "Aceitar edições", "permissionAcceptEditsDescription": "Aprova edições e comandos de arquivo; pede aprovação para o restante", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d99b7f12c03..d4ee3421ba7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18904,7 +18904,6 @@ "permissions": "权限", "permissionAsk": "请求批准", "permissionAskDescription": "在设置未允许的编辑和命令前询问", - "permissionAskCodexDescription": "在此工作区中编辑文件并运行命令;其他操作前会询问", "permissionAskSharedDescription": "在执行尚未获准的编辑或命令前询问", "permissionAcceptEdits": "接受编辑", "permissionAcceptEditsDescription": "批准文件编辑和文件命令;其余操作会询问", diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts index 9658da0ef4d..5c4e0ebfd36 100644 --- a/src/shared/agent-session-options-result.ts +++ b/src/shared/agent-session-options-result.ts @@ -35,7 +35,7 @@ export type AgentSessionOptionsResult = { serviceTier?: string /** The permission mode a start runs now; absent from a host or agent with no picker. */ permissionMode?: string - /** Agent report for own settings: null means reported but not a named Orca mode. */ + /** Current startup report or accepted fresh-turn policy: null means reported but not a named Orca mode. */ reportedPermissionMode?: string | null /** Provider-reported effective routing, distinct from the next-turn preference. */ fastModeState?: AgentSessionFastModeState diff --git a/src/shared/promise-settlement-waiters.ts b/src/shared/promise-settlement-waiters.ts index 492f5619b5f..7089cec107c 100644 --- a/src/shared/promise-settlement-waiters.ts +++ b/src/shared/promise-settlement-waiters.ts @@ -88,7 +88,16 @@ export class PromiseSettlementWaiters { ), Math.max(1, options.timeoutMs) ) - waiter.timer.unref?.() + // Mobile timers are numeric; Node timers can detach from the event loop. + const timer: unknown = waiter.timer + if ( + typeof timer === 'object' && + timer !== null && + 'unref' in timer && + typeof timer.unref === 'function' + ) { + timer.unref() + } } this.waiters.add(waiter) options.signal?.addEventListener('abort', onAbort, { once: true }) diff --git a/src/shared/structured-agent-session-permission-option.test.ts b/src/shared/structured-agent-session-permission-option.test.ts index be9bd4f7de5..d4a95ed951a 100644 --- a/src/shared/structured-agent-session-permission-option.test.ts +++ b/src/shared/structured-agent-session-permission-option.test.ts @@ -87,7 +87,7 @@ describe('the permission mode picker', () => { { value: 'ask', label: 'Ask for approval', - description: 'Edits files and runs commands in this workspace; asks before anything else' + description: "Asks before edits or commands it isn't already allowed to make" }, { value: 'auto', label: 'Approve for me', description: 'Reviews approval requests for you' }, { diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index a191d8fae8d..9f3e44a251f 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -29,16 +29,16 @@ const MODE_COPY: Readonly> = { - ask: 'Edits files and runs commands in this workspace; asks before anything else', - 'full-access': 'Never asks; can edit any file and use the network' -} - // Claude's copy names the person's own settings; other agents decide what they already allow. const OTHER_AGENT_DESCRIPTIONS: Partial> = { ask: "Asks before edits or commands it isn't already allowed to make" } +const CODEX_DESCRIPTIONS: Partial> = { + ...OTHER_AGENT_DESCRIPTIONS, + 'full-access': 'Never asks; can edit any file and use the network' +} + const ANTIGRAVITY_DESCRIPTIONS: Partial> = { ...OTHER_AGENT_DESCRIPTIONS, 'accept-edits': 'Approves file edits; asks for the rest' diff --git a/tests/e2e/structured-permission-report-publication.unit.test.ts b/tests/e2e/structured-permission-report-publication.unit.test.ts index ef87bfd95c0..7f5ed592732 100644 --- a/tests/e2e/structured-permission-report-publication.unit.test.ts +++ b/tests/e2e/structured-permission-report-publication.unit.test.ts @@ -1,4 +1,9 @@ // @vitest-environment happy-dom +import { + fakeCodex, + THREAD_ID as CODEX_THREAD_ID +} from '../../src/main/codex/codex-structured-session-adapter-fixture' +import { structuredAgentRuntimeRegistration } from '../../src/main/runtime/structured-agent-runtime-registrations' import { act, renderHook, waitFor, cleanup } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import type { SDKStatusMessage } from '@anthropic-ai/claude-agent-sdk' @@ -451,3 +456,140 @@ it.each([ } } ) + +it('publishes accepted Codex fresh policy to desktop and phone without stale startup readback', async () => { + const { host, store, root } = hostTestState() + const answer = Promise.withResolvers() + const codex = fakeCodex({ 'turn/start': () => answer.promise }) + codex.routes['thread/start'] = () => ({ + thread: { id: CODEX_THREAD_ID }, + model: 'gpt-live', + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: { type: 'workspaceWrite' } + }) + const settlements: Promise[] = [] + const adapter = structuredAgentRuntimeRegistration('codex')!.createAdapter({ + deps: { + ...host.deps, + stateDirectory: root, + hostId: 'local', + resolveWorkspacePath: async () => root, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' as const }), + resolveCodexCommand: () => 'codex', + readCodexInstallation: async () => ({ version: '0.136.0' }), + readProcessStartTime: async () => 1_700_000_000_000, + openCodexConnection: codex.openConnection + }, + store, + journalDatabase: host.deps.journalDatabase, + environment: { + resolveBaseEnvironment: async () => ({}), + resolveAgentEnvironment: async () => ({}), + refreshAfterMissingProgram: async () => {} + }, + host: () => host, + followUps: { onDispatchSettledLate: () => {} }, + deliverLifecycle: (event) => { + settlements.push(host.handleAdapterEvent(event)) + } + }) + host.deps.adapter = adapter + let sending: ReturnType | undefined + try { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY]) + expect( + ( + await host.attach( + CALLER, + attachParams({ + providerHandle: undefined, + options: { permissionMode: 'own' } + }) + ) + ).ok + ).toBe(true) + await waitFor(() => expect(host.readStatusSummary(sessionId)?.hostExecutionPhase).toBe('ready')) + const fence = store.getRecord(sessionId)!.lease.runtimeFence + const child = host.collaboratorsForTests().sessions.get(sessionId)!.child! + const { mount } = permissionConsumersFor(host, 'codex', fence) + const views = mount() + const descriptions = () => + views.map( + (view) => + view.result.current.optionSnapshot.find((row) => row.id === 'permissionMode')?.description + ) + await waitFor(() => expect(descriptions()).toEqual(['Approve for me', 'Approve for me'])) + const desktop = views[0]! + await act(async () => { + expect(await desktop.result.current.setStructuredOption('permissionMode', 'ask')).toBe(true) + }) + const saved = structuredClone(store.getRecord(sessionId)) + const staleRead = (await adapter.prepareReadOptions!({ sessionId, fence }))! + sending = adapter.dispatch({ + sessionId, + clientMessageId: 'same-child-ask', + body: USER_MESSAGE, + fence, + admitFreshInput: async (enqueue) => enqueue('ask', false) + }) + const finished = sending.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + await waitFor(() => + expect(codex.connections[0]!.calls.some((call) => call.method === 'turn/start')).toBe(true) + ) + codex.connections[0]!.handlers.onNotification?.('turn/started', { + threadId: CODEX_THREAD_ID, + turn: { id: 'applied-ask' } + }) + expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ fence, mode: 'auto' }) + expect(descriptions()).toEqual(['Approve for me', 'Approve for me']) + await act(async () => { + answer.resolve({ turn: { id: 'applied-ask' } }) + expect(await finished).toMatchObject({ value: { state: 'admitted' } }) + await Promise.all(settlements) + }) + await waitFor(() => expect(descriptions()).toEqual(['Ask for approval', 'Ask for approval'])) + expect(staleRead().current.reportedPermissionMode).toBe('ask') + expect(store.getRecord(sessionId)).toEqual(saved) + expect((await host.readOptions(sessionId)).current).toMatchObject({ + permissionMode: 'ask', + reportedPermissionMode: 'ask' + }) + await act(async () => { + await host.handleAdapterEvent({ + type: 'options-reported', + sessionId, + fence: fence - 1, + acquisitionGeneration: 'retired-child', + reportedOptions: { model: 'gpt-live', reportedPermissionMode: 'auto' } + }) + }) + expect(host.readStatusSummary(sessionId)?.runtimePermission).toEqual({ fence, mode: 'ask' }) + expect(host.collaboratorsForTests().sessions.get(sessionId)!.child).toBe(child) + expect(codex.connections).toHaveLength(1) + const starts = codex.connections[0]!.calls.filter((call) => call.method === 'turn/start') + expect(starts).toHaveLength(1) + expect(starts[0]!.params).toMatchObject({ + clientUserMessageId: 'same-child-ask', + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandboxPolicy: { type: 'readOnly' } + }) + views.forEach((view) => view.unmount()) + } finally { + answer.resolve({ turn: { id: 'applied-ask' } }) + await sending?.catch(() => {}) + await Promise.all(settlements) + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + resetHostModelCatalogSnapshotsForTests() + await host.close(sessionId, 'user-close') + await adapter.closeAll() + await Promise.all(settlements) + setLocalRuntimeCapabilitiesForTests(null) + } +})