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
This commit is contained in:
Brennan Benson
2026-10-26 10:50:00 -07:00
committed by GitHub
parent bde231bc9e
commit 532f6a0e00
77 changed files with 4344 additions and 387 deletions
@@ -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 () => {
@@ -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<CodexAppServerConnection, 'request'>,
input: { cwd: string; visualsFolder?: string },
platform: NodeJS.Platform = process.platform
): Promise<CodexSandboxPolicy | undefined> {
// 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
}
}
@@ -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<void>((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' })
@@ -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<CodexStructuredSessionAdapter> {
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
@@ -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' }
@@ -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<StructuredAgentSessionAdapter['dispatch']>[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<string, unknown>[] {
const input: Record<string, unknown>[] = []
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
}
@@ -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'
})
})
@@ -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(
@@ -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<NonNullable<StructuredAgentSessionAdapter['compact']>>[0],
requestTimeoutMs: number | undefined
): ReturnType<NonNullable<StructuredAgentSessionAdapter['compact']>> {
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
}
}
@@ -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'
}
}
@@ -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({
@@ -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<typeof sandboxPolicy>
/** 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
}
@@ -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' },
@@ -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<AgentSessionAcquisition> =>
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<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
/** The reply acknowledges receipt; the translator observes turn completion separately. */
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = 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<StructuredAgentSessionAdapter['compact']> = (input) =>
compactCodexStructuredSession(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
changeThreadGoal: NonNullable<StructuredAgentSessionAdapter['changeThreadGoal']> = (input) => {
const session = this.session(input.sessionId)
@@ -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'] {
@@ -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,
@@ -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<string>
/** Interrupt-acknowledged turns cannot accept steers while awaiting turn/completed. */
@@ -152,6 +164,7 @@ export type CodexSession = {
dispatchPending?: boolean
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
/** Startup reports and accepted fresh-turn policies describe this child, never saved intent. */
reportedOptions: {
reportedPermissionMode?: string | null
model?: string
@@ -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(
+17 -1
View File
@@ -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, unknown>): string | null {
export function reportedCodexPermissionMode(reply: Record<string, unknown>): 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'
}
@@ -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<unknown>()
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<unknown>()
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()
}
}
)
@@ -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<CodexSession['connection'], 'request'>
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<void> {
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<typeof codexTurnPermission>,
body?: AgentJournalMessageItem
) => Extract<StructuredAgentSessionInputAdmission, { kind: 'sent' }>,
signal?: AbortSignal
): Promise<unknown> {
let prepared = false
const enqueue: Parameters<StructuredAgentSessionAdmitFreshInput>[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
}
}
+84 -48
View File
@@ -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<CodexSkillReport, 'hasSkillPath'>
connection: Pick<CodexAppServerConnection, 'request'>
threadId: string
permissions?: CodexSession['permissions']
options: Map<string, string>
appliedTurnOptions?: Readonly<Record<string, string>>
appliedSettings?: CodexSession['appliedSettings']
@@ -56,31 +79,6 @@ export type CodexTurnHost = {
turnOpenWaits: Pick<CodexTurnOpenWaits, 'wait'>
}
function turnInputFor(
host: CodexTurnHost,
body: AgentJournalMessageItem
): Record<string, unknown>[] {
const input: Record<string, unknown>[] = []
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<Record<string, string>>
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<Parameters<typeof admitCodexTurnPermission>[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<Record<string, string>>
signal?: AbortSignal
isCurrent?: () => boolean
admitFreshInput?: StructuredAgentSessionAdmitFreshInput
},
timeoutMs: number | undefined
): Promise<AgentSessionDispatchOutcome> {
@@ -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)
@@ -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<JournalRow, { kind: 'dispatch' }>
@@ -84,6 +85,8 @@ function foldStoppedHistoryCarried(
: []
if (carried.length > 0) {
state.stoppedHistoryCarried.set(row.clientMessageId, carried)
} else {
state.stoppedHistoryCarried.delete(row.clientMessageId)
}
}
@@ -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<number | null> {
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)
@@ -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'))
}
}
}
@@ -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<StructuredAgentSessionAdapter['rewindSupport']> = (
sessionId,
@@ -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<boolean>
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<void>
/** 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. */
@@ -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)))
},
@@ -69,7 +69,7 @@ export type StructuredAgentSessionChildExitContext<
startupAttempts?: Pick<StructuredAgentSessionStartupAttempts, 'childEnded'>
/** Retain exits between background-start spawn and publication. */
startFlights?: Pick<StructuredAgentSessionStartFlights, 'deferExit' | 'ownsChild'>
abortOpen?: (sessionId: string) => void
abortBound?: (sessionId: string) => void
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
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
@@ -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<void>()
const entered = Promise.withResolvers<void>()
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()
}
}
)
@@ -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<unknown>()
const held = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
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<void>()
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<unknown>()
let signal: AbortSignal | undefined
const lateReads: Promise<unknown>[] = []
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)
}
}
)
@@ -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<unknown>()
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<unknown>()
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<unknown>()
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<void>()
const entered = Promise.withResolvers<void>()
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<boolean>>()
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<unknown>()
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<void>()
const entered = Promise.withResolvers<void>()
const outcomes: Promise<unknown>[] = []
const retryEntered = Promise.withResolvers<void>()
const retryReady = Promise.withResolvers<void>()
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<typeof open>[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<unknown> = 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()
}
})
}
@@ -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<typeof codexTurnLifecycleFake>
let codex: ReturnType<typeof fakeCodex>
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<string, unknown>) {
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<string, string>) {
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<unknown>()
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<unknown>()
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<void>()
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<unknown>()
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<unknown>()
codex.routes['turn/steer'] = () => refusal.promise
await host.flushStreamedEvents(SESSION)
const measured =
population > 1 ? measureCodexInputWork(host, events, 'history', population) : undefined
let waiting: ReturnType<typeof host.waitForSendSettlement> | undefined
try {
if (mode === 'auto-before') {
await pick('auto')
}
const id = await permissionSend('follow up')
if (measured) {
waiting = host.waitForSendSettlement(SESSION, id)
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<unknown>()
const responseObserved = lost.promise.catch(() => {})
const replyLost = new CodexAppServerTimeoutError('reply lost after the write')
const entered = Promise.withResolvers<void>()
const delivery = Promise.withResolvers<void>()
const frameDelivery = Promise.withResolvers<void>()
const forwarded: Promise<unknown>[] = []
codex.routes['turn/start'] = () => lost.promise
let stopping: Promise<unknown> | 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<void> {
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)
@@ -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<string, Record<string, number>>()
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<void>[] = []
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()
}
}
}
}
@@ -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
})
)
}
}
@@ -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<T> => {
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<void> => {
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 () => {
@@ -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))
)
}
@@ -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,
@@ -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: <T>(start: Promise<T>) => Promise<T>
startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim
/** Retire mismatched login/permission children under serialization. */
retireOutgrownChild: (sessionId: string) => Promise<void>
retireOutgrownChild: (sessionId: string, continuingId?: string) => Promise<void>
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<void>
settleLeftovers?: (sessionId: string, owner: () => void) => Promise<void>
readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined
childWorkOpen: (sessionId: string) => boolean
stopping: (sessionId: string) => boolean
@@ -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'
}
@@ -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'
@@ -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<AgentSessionJournal, 'cursor'>
/** The chat's journal, which identifies the conversation for as long as it is held. */
type HandoverJournal = Pick<UnopenedSendJournal, 'submissions'>
// Late settlement can overlap its own send’s handover; retain nested counts.
const handingOver = new WeakMap<HandoverJournal, Map<string, number>>()
const NONE: ReadonlyMap<string, number> = new Map()
type HandoverInput = {
owner: () => void
unadmitted: boolean
signal?: AbortSignal
pending?: () => boolean
finalization: { close: (cause: UnsentSendHold['cause']) => void; completion: Promise<void> }
}
type Handover = { count: number; input?: HandoverInput }
const handingOver = new WeakMap<HandoverJournal, Map<string, Handover>>()
const NONE: ReadonlyMap<string, Handover> = 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<void> | 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<string, number> {
): ReadonlyMap<string, Handover> {
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<AgentSessionJournal, 'submissions'>,
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<T>(
journal: HandoverJournal,
clientMessageId: string,
@@ -33,25 +102,42 @@ export async function whileStructuredAgentSessionHandingOver<T>(
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<void>()
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
}
@@ -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<StructuredAgentSessionAcquireAborts, 'begin'>
deliveryOrder?: StructuredAgentSessionDeliveryOrder
workChanged?: () => void
/** Retain executed options only when the chat has no saved choice. */
recordRanOptions?: ReturnType<StructuredAgentSessionRanRecorder>
}
/** One input operation runs provider work outside ordering and commits its result inside it. */
export type StructuredAgentSessionPendingHandover = {
dispatch: () => Promise<() => Promise<void>>
release?: () => void
run: (commit: (resolve: () => Promise<void>) => Promise<void>) => Promise<void>
}
/** 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<StructuredAgentSessionPendingHandover | null> {
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<AgentJournalMessageItem> => {
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<void> {
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<AgentJournalMessageItem>,
signal?: AbortSignal
): Promise<AgentSessionDispatchOutcome> {
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<AgentSessionDispatchOutcome, { state: 'rejected' }> {
return {
state: 'rejected',
...agentSessionFailureWords(
@@ -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,
@@ -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<boolean> {
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<string, StructuredAgentSessionHostSession>
serialize: (sessionId: string, task: () => Promise<void>) => Promise<void>
closeConversation: (sessionId: string) => Promise<boolean>
deps: () => Pick<StructuredAgentSessionHostDeps, 'logger'>
},
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<void>) => Promise<void>
@@ -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<void> {
for (const session of context.sessions.values()) {
closeStructuredAgentSessionHandovers(session.journal, 'hostRestarted')
}
const retainSessionIds = new Set<string>()
await tearDownStructuredAgentSessionHost({
phases: structuredAgentSessionHostTeardownPhases({
@@ -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
@@ -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<StructuredAgentSessionAcquireAborts, 'begin'>
deliveryOrder?: StructuredAgentSessionDeliveryOrder
workChanged?: () => void
holdClosedInput?: (
clientMessageId: string,
fence: number,
cause: UnsentSendHold['cause']
) => Promise<void>
settleEndedInput?: (owner: () => void) => Promise<void>
/** A message the agent took: a chat keeps, where it has none saved, the options it ran. */
recordRanOptions?: ReturnType<StructuredAgentSessionRanRecorder>
}
export type StructuredAgentSessionConversationLifetime = ReturnType<
typeof createStructuredAgentSessionConversationLifetime
>
export type StructuredAgentSessionConversationDelivery = {
loop: StructuredAgentSessionDeliveryLoop
open: (
sessionId: string,
options?: StructuredAgentSessionConversationOpenOptions
) => Promise<StructuredAgentSessionHostSession | null>
/** 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<void>
}
@@ -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<unknown> }
/** 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<StructuredAgentSessionInputAdmission>
export type StructuredAgentSessionAdmitHostInput = (
enqueue: Parameters<StructuredAgentSessionAdmitFreshInput>[0],
signal: AbortSignal | undefined,
prepare: (fence: number) => Promise<AgentJournalMessageItem>
) => ReturnType<StructuredAgentSessionAdmitFreshInput>
export type StructuredAgentSessionContinueInput = (
signal: AbortSignal | undefined,
rebind: (fence: number) => Promise<void>
) => 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<AgentSessionDispatchOutcome, { state: 'rejected' }>) {
super('input was not admitted')
}
}
@@ -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<ReturnType<typeof startThatFailedWhileQueued>>
/** 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<StructuredAgentSessionPendingHandover | null> {
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
)
}
@@ -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<AgentSessionDispatchOutcome>()
const nested = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
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<void> | undefined
let observed: Promise<void> | 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<void>((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')
@@ -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<StructuredAgentSessionHostDeps, 'agents'>,
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<typeof continuingStructuredAgentSessionInput>[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<StructuredAgentSessionAttachContext, 'deps' | 'sessions'>,
options: { startedFor?: string },
lock: Pick<StructuredAgentSessionAttachLock, 'wait'>
): (record: AgentSessionRecord) => Promise<AgentChatPermissionChoice | undefined> {
return async (record) =>
structuredAgentSessionInputCompilerPermission(
context.sessions.get(record.sessionId)?.journal,
options.startedFor
) ??
(await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ??
undefined
}
@@ -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 }
}
@@ -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<void> {
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
}
}
@@ -188,12 +188,14 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
input: Parameters<typeof conversation>[0],
work: Partial<typeof idle> = {},
stopAgent = vi.fn(async () => undefined),
launchPermission?: () => Promise<AgentChatPermissionChoice | null>
launchPermission?: () => Promise<AgentChatPermissionChoice | null>,
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')),
@@ -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<AgentChatPermissionChoice | null>) | undefined
current: (() => Promise<AgentChatPermissionChoice | null>) | undefined,
requiresAcquisition: ((mode: AgentChatPermissionChoice) => boolean) | undefined
): Promise<boolean> {
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<AgentChatPermissionChoice | null>
permissionRequiresAcquisition?: (mode: AgentChatPermissionChoice) => boolean
stopAgent: (sessionId: string) => Promise<void>
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<AgentSessionJournal, 'newestTurn'>,
work: StructuredAgentSessionChildWorkReads & {
childWork: () => readonly AgentChildWorkView[] | undefined
}
): boolean {
return structuredAgentSessionChildHasOpenWork(work) || owesWakeUp(journal, work.childWork())
}
@@ -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()
@@ -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 {
@@ -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
)
}
@@ -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))
}),
@@ -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
}
@@ -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": [],
@@ -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)
}
}
@@ -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'
@@ -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',
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -18938,7 +18938,6 @@
"permissions": "権限",
"permissionAsk": "承認を求める",
"permissionAskDescription": "設定で許可されていない編集やコマンドの前に確認します",
"permissionAskCodexDescription": "このワークスペース内でファイルを編集しコマンドを実行します。それ以外の操作の前に確認します",
"permissionAskSharedDescription": "まだ許可されていない編集やコマンドの前に確認します",
"permissionAcceptEdits": "編集を許可",
"permissionAcceptEditsDescription": "ファイルの編集とファイル操作コマンドを承認し、それ以外は確認します",
-1
View File
@@ -18938,7 +18938,6 @@
"permissions": "권한",
"permissionAsk": "승인 요청",
"permissionAskDescription": "설정에서 허용하지 않은 편집과 명령 전에 묻습니다",
"permissionAskCodexDescription": "이 작업 공간에서 파일을 편집하고 명령을 실행하며, 그 외 작업 전에는 묻습니다",
"permissionAskSharedDescription": "아직 허용되지 않은 편집이나 명령 전에 묻습니다",
"permissionAcceptEdits": "편집 허용",
"permissionAcceptEditsDescription": "파일 편집과 파일 명령은 승인하고, 나머지는 묻습니다",
-1
View File
@@ -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",
-1
View File
@@ -18904,7 +18904,6 @@
"permissions": "权限",
"permissionAsk": "请求批准",
"permissionAskDescription": "在设置未允许的编辑和命令前询问",
"permissionAskCodexDescription": "在此工作区中编辑文件并运行命令;其他操作前会询问",
"permissionAskSharedDescription": "在执行尚未获准的编辑或命令前询问",
"permissionAcceptEdits": "接受编辑",
"permissionAcceptEditsDescription": "批准文件编辑和文件命令;其余操作会询问",
+1 -1
View File
@@ -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
+10 -1
View File
@@ -88,7 +88,16 @@ export class PromiseSettlementWaiters<T> {
),
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 })
@@ -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' },
{
@@ -29,16 +29,16 @@ const MODE_COPY: Readonly<Record<AgentChatPermissionMode, { label: string; descr
}
}
const CODEX_DESCRIPTIONS: Partial<Record<AgentChatPermissionMode, string>> = {
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<Record<AgentChatPermissionMode, string>> = {
ask: "Asks before edits or commands it isn't already allowed to make"
}
const CODEX_DESCRIPTIONS: Partial<Record<AgentChatPermissionMode, string>> = {
...OTHER_AGENT_DESCRIPTIONS,
'full-access': 'Never asks; can edit any file and use the network'
}
const ANTIGRAVITY_DESCRIPTIONS: Partial<Record<AgentChatPermissionMode, string>> = {
...OTHER_AGENT_DESCRIPTIONS,
'accept-edits': 'Approves file edits; asks for the rest'
@@ -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<unknown>()
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<void>[] = []
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<typeof adapter.dispatch> | 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)
}
})