mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
+68
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+356
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
+760
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
+796
@@ -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)
|
||||
+159
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-1
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-25
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+12
-1
@@ -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
|
||||
)
|
||||
}
|
||||
+84
@@ -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
|
||||
}
|
||||
|
||||
+16
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18938,7 +18938,6 @@
|
||||
"permissions": "権限",
|
||||
"permissionAsk": "承認を求める",
|
||||
"permissionAskDescription": "設定で許可されていない編集やコマンドの前に確認します",
|
||||
"permissionAskCodexDescription": "このワークスペース内でファイルを編集しコマンドを実行します。それ以外の操作の前に確認します",
|
||||
"permissionAskSharedDescription": "まだ許可されていない編集やコマンドの前に確認します",
|
||||
"permissionAcceptEdits": "編集を許可",
|
||||
"permissionAcceptEditsDescription": "ファイルの編集とファイル操作コマンドを承認し、それ以外は確認します",
|
||||
|
||||
@@ -18938,7 +18938,6 @@
|
||||
"permissions": "권한",
|
||||
"permissionAsk": "승인 요청",
|
||||
"permissionAskDescription": "설정에서 허용하지 않은 편집과 명령 전에 묻습니다",
|
||||
"permissionAskCodexDescription": "이 작업 공간에서 파일을 편집하고 명령을 실행하며, 그 외 작업 전에는 묻습니다",
|
||||
"permissionAskSharedDescription": "아직 허용되지 않은 편집이나 명령 전에 묻습니다",
|
||||
"permissionAcceptEdits": "편집 허용",
|
||||
"permissionAcceptEditsDescription": "파일 편집과 파일 명령은 승인하고, 나머지는 묻습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18904,7 +18904,6 @@
|
||||
"permissions": "权限",
|
||||
"permissionAsk": "请求批准",
|
||||
"permissionAskDescription": "在设置未允许的编辑和命令前询问",
|
||||
"permissionAskCodexDescription": "在此工作区中编辑文件并运行命令;其他操作前会询问",
|
||||
"permissionAskSharedDescription": "在执行尚未获准的编辑或命令前询问",
|
||||
"permissionAcceptEdits": "接受编辑",
|
||||
"permissionAcceptEditsDescription": "批准文件编辑和文件命令;其余操作会询问",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user