diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index cbc9eef6fa62..a0abbcee8473 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -514,11 +514,10 @@ describe.skipIf(process.platform === 'win32')('Daemon mock complete Git change e expect(flags).toContain('test=true\n') expect(flags).toContain('typecheck=true\n') }) - it('routes the complete four-file modification through the real detector', () => { + it('routes the complete three-file modification through the real detector', () => { const companions = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', - 'config/scripts/process-host-build-dist.test.mjs', - 'src/main/codex/codex-app-server-connection.test.ts' + 'config/scripts/process-host-build-dist.test.mjs' ] const repo = createMobileCiScopeRepository(true, helper, companions) for (const file of [helper, ...companions]) { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index a456f8a4688b..6474066d2c16 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -508,8 +508,7 @@ export const DAEMON_INIT_TEST_SUPPORT = 'src/main/daemon/daemon-init-dependency- const DAEMON_INIT_TEST_SCOPE = new Set([ DAEMON_INIT_TEST_SUPPORT, 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', - 'config/scripts/process-host-build-dist.test.mjs', - 'src/main/codex/codex-app-server-connection.test.ts' + 'config/scripts/process-host-build-dist.test.mjs' ]) export function isDaemonInitTestSupportChange(files, modifiedOnly = false) { diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 14638aafb29b..d01a4f2347d6 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -283,7 +283,7 @@ describe('Daemon mock modified-only source routing', () => { it('omits the real D7 route only for the proven unit-support scope', () => { expect(selectPrE2eSpecs([helper])).toContain('tests/e2e/orcad-serve-mode-switch.spec.ts') expect(selectPrE2eSpecs([helper], undefined, verified)).toEqual([]) - const companion = 'src/main/codex/codex-app-server-connection.test.ts' + const companion = 'config/scripts/process-host-build-dist.test.mjs' expect(selectPrE2eSpecs([helper, companion], undefined, verified)).toEqual( selectPrE2eSpecs([companion]) ) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index acba95ffd32f..fea577a9065d 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -233,7 +233,9 @@ describe('closing or stopping a Grok chat while it starts', () => { { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } ]) const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items - expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + expect(rows.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : []))).toEqual([ + 'Cancellation requested.' + ]) await host.close(SESSION, 'user-close') }) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 17c717e1ac2f..9a8d2c74c33e 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -209,5 +209,7 @@ async function expectDeliveredAfterStop( ]) await host.flushStreamedEvents(SESSION) const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items - expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + expect(rows.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : []))).toEqual([ + 'Cancellation requested.' + ]) } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 072dd32deceb..f49760b7ae4a 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -224,9 +224,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { cancelled: true } } - // End the child so background work cannot reopen a turn after Stop; the next send reloads. - stopEndsSession = (): boolean => true - awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { const session = this.children.sessions.get(sessionId) if (session && isAcpReadySession(session)) { diff --git a/src/main/claude/claude-close-turn-end.test.ts b/src/main/claude/claude-close-turn-end.test.ts index 422ae6e1c391..92b6c8c4b0f7 100644 --- a/src/main/claude/claude-close-turn-end.test.ts +++ b/src/main/claude/claude-close-turn-end.test.ts @@ -52,13 +52,12 @@ async function childInsideTurn() { describe('a Claude close settles the open turn with no verdict of its own', () => { it('ends it interrupted, with no outcome', async () => { - const { adapter, events, turns } = await childInsideTurn() + const { adapter, turns } = await childInsideTurn() await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) expect(turns.at(-1)).not.toHaveProperty('outcome') - expect(events.find((event) => event.type === 'ended')).not.toHaveProperty('stopCause') }) it('settles a crash it saw before the close once, as that exit', async () => { diff --git a/src/main/claude/claude-scripted-child.test-fixture.ts b/src/main/claude/claude-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..dfde01fe413b --- /dev/null +++ b/src/main/claude/claude-scripted-child.test-fixture.ts @@ -0,0 +1,164 @@ +// Claude's scripted child for the registration matrix: the real Claude adapter over a scripted +// stream-json connection. Its handshake is the CLI's initialize answer. + +import { randomUUID } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import type { + ScriptedAgentChild, + ScriptedAgentChildFactory +} from '../runtime/structured-agent-scripted-child.test-fixture' +import { scriptedClaudeExitError } from '../runtime/structured-claude-scripted-runtime-test-support' +import { result, says } from './claude-captured-frame-builders.test-fixture' + +type Spawned = { + connection: Omit & { + closed: boolean + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + } + answer: () => void + fail: (message: string) => void +} + +function promptText(message: Record): string { + const inner = message.message + const content = + inner && typeof inner === 'object' && 'content' in inner ? inner.content : undefined + const blocks = Array.isArray(content) ? content : [] + return blocks + .map((block: unknown) => + block && typeof block === 'object' && 'text' in block ? String(block.text) : '' + ) + .join('') +} + +/** The CLI writes its transcript as a conversation's first message lands; a later start resumes it. */ +function writeTranscript(launch: ClaudeStreamJsonLaunch, providerSessionId: string): void { + const home = launch.env?.CLAUDE_CONFIG_DIR + if (!home) { + return + } + const folder = join(home, 'projects', 'scripted') + mkdirSync(folder, { recursive: true }) + writeFileSync(join(folder, `${providerSessionId}.jsonl`), '{}\n') +} + +export const claudeScriptedChild: ScriptedAgentChildFactory = () => { + const spawned: Spawned[] = [] + const prompts: string[] = [] + let resumes = 0 + let closes = 0 + const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { + if (launch.options.resume !== undefined) { + resumes += 1 + } + const providerSessionId = String(launch.options.sessionId ?? launch.options.resume) + const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } + let answer = (): void => {} + let reject = (_error: Error): void => {} + const init = new Promise((resolve, fail) => { + answer = () => { + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: providerSessionId, + model: 'claude-sonnet-5', + apiKeySource: 'none' + }) + resolve(initialized) + } + reject = fail + }) + if (!child.holdHandshakes) { + answer() + } + const connection: Spawned['connection'] = { + pid: 7000 + spawned.length, + closed: false, + exitVerdict: { root: 'live', tree: 'unverifiable' }, + initializationResult: () => init, + getContextUsage: async () => ({}), + getSettings: async () => ({ effective: {} }), + supportedModels: async () => [{ value: 'sonnet', displayName: 'Sonnet' }], + setModel: async () => {}, + setPermissionMode: async () => {}, + applyFlagSettings: async () => {}, + interrupt: async () => undefined, + cancelAsyncMessage: async () => false, + stopTask: async () => {}, + send: async (message, beforeDispatch) => { + await beforeDispatch?.() + prompts.push(promptText(message)) + writeTranscript(launch, providerSessionId) + // The CLI echoes each message it takes under the client's uuid: Orca's proof of delivery. + handlers.onMessage?.({ + type: 'user', + session_id: providerSessionId, + parent_tool_use_id: null, + uuid: message.uuid, + isReplay: true, + message: message.message + }) + if (child.completeTurns) { + const id = randomUUID() + handlers.onMessage?.({ ...says(id, 'done', null), session_id: providerSessionId }) + handlers.onMessage?.({ ...result('success'), session_id: providerSessionId, uuid: id }) + } + }, + close: async () => { + if (!connection.closed) { + connection.closed = true + closes += 1 + connection.exitVerdict = { root: 'exited', tree: 'exited' } + handlers.onExit?.(new Error('closed by Orca')) + } + return true + } + } + spawned.push({ + connection, + answer, + fail: (message) => { + const error = scriptedClaudeExitError(message) + connection.closed = true + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + reject(error) + handlers.onExit?.(error) + } + }) + return connection + } + const newest = (): Spawned => { + const latest = spawned.at(-1) + if (!latest) { + throw new Error('no Claude child was spawned') + } + return latest + } + const child: ScriptedAgentChild = { + deps: { + openClaudeConnection: openConnection, + readProcessStartTime: async (pid) => + spawned.find((entry) => entry.connection.pid === pid)?.connection.exitVerdict.root === + 'exited' + ? null + : pid * 10, + resolveClaudeCommand: () => '/nonexistent/scripted-claude', + resolveClaudeAuthPolicy: () => ({ account: 'system' }) + }, + holdHandshakes: true, + releaseHandshake: () => newest().answer(), + failHandshake: (message) => newest().fail(message), + completeTurns: true, + prompts: () => prompts, + spawns: () => spawned.length, + resumes: () => resumes, + closes: () => closes + } + return child +} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index babc76898c9f..b1e960c780c7 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -38,6 +38,54 @@ function sessionWith(input: { } describe('cancelClaudeTurn', () => { + it.each(['deadline', 'replacement'])( + 'bounds the legacy withdrawal sweep and retains confirmed sends: %s', + async (mode) => { + let current = true + const { session, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1'], + interrupt: async () => { + vi.setSystemTime(2_900) + return { still_queued: ['queued-1', 'queued-2', 'queued-3'] } + } + }) + cancelAsyncMessage.mockImplementation( + async (uuid: string, options: { timeoutMs: number }) => { + if (uuid === 'queued-1') { + current = mode !== 'replacement' + return true + } + return runClaudeControl( + 'cancel_async_message', + () => new Promise(() => {}), + options.timeoutMs + ) + } + ) + const settled = vi.fn() + session.connection.send = vi.fn(async () => { + throw new Error('lost write') + }) + await dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'queued' }]) + }) + session.retiredDispatchWaiters[0]!.sentUuid = 'queued-1' + vi.useFakeTimers() + vi.setSystemTime(0) + try { + const stopping = cancelClaudeTurn(session, 3_000, () => current, settled, 0) + await vi.advanceTimersByTimeAsync(100) + expect(await stopping).toEqual({ cancelled: true }) + expect(cancelAsyncMessage).toHaveBeenCalledTimes(mode === 'deadline' ? 2 : 1) + expect(settled).toHaveBeenCalledOnce() + expect(session.retiredDispatchWaiters).toEqual([]) + } finally { + vi.useRealTimers() + } + } + ) + it('interrupts without a receipt on an older CLI and reports the turn cancelled', async () => { const { session, interrupt, cancelAsyncMessage } = sessionWith({ interrupt: async () => undefined diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 6def54c1abbd..480eb20fcf98 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -6,6 +6,7 @@ import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-req import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +import { CLAUDE_STOP_GRACE_MS } from './claude-request-end-wait' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -20,24 +21,38 @@ export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard = () => true, - onDispatchSettledLate?: ClaudeLateDispatchSettlement + onDispatchSettledLate?: ClaudeLateDispatchSettlement, + stoppedAt?: number ): Promise<{ cancelled: boolean }> { // Interrupt is session-scoped; recheck ownership immediately before sending. - if (!isCurrent()) { + const remaining = () => + stoppedAt === undefined + ? timeoutMs + : Math.min(timeoutMs ?? CLAUDE_STOP_GRACE_MS, stoppedAt + CLAUDE_STOP_GRACE_MS - Date.now()) + const interruptTimeout = remaining() + if (!isCurrent() || (interruptTimeout !== undefined && interruptTimeout <= 0)) { return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), - timeoutMs + timeoutMs: interruptTimeout }) if (cancelQueued) { settleCancelledClaudeDispatchWaiters(session, receipt?.cancelled ?? [], onDispatchSettledLate) } else { const withdrawn: string[] = [] for (const uuid of receipt?.still_queued ?? []) { - if (await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => false)) { + const withdrawalTimeout = remaining() + if (!isCurrent() || (withdrawalTimeout !== undefined && withdrawalTimeout <= 0)) { + break + } + if ( + await session.connection + .cancelAsyncMessage(uuid, { timeoutMs: withdrawalTimeout }) + .catch(() => false) + ) { withdrawn.push(uuid) } } diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index da4209a2fe95..9c1061241d32 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -42,7 +42,13 @@ function cancelClaudeConversation( session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) - return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) + return cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + onDispatchSettledLate, + request.stoppedAt ?? Date.now() + ) } export async function cancelClaudeStructuredTurn(input: { @@ -108,7 +114,8 @@ export async function cancelClaudeStructuredTurn(input: { } return current }, - input.onDispatchSettledLate + input.onDispatchSettledLate, + request.stoppedAt ?? Date.now() ) } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index f8125258db14..e2534bd36b8d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -171,8 +171,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - // End the session on Stop: interrupts may leave the turn or background work running. - stopEndsSession = (): boolean => true awaitStoppedRequestEnd = claudeStoppedRequestEndWait(this.sessions) routePromptCancel = claudePromptCancelRoute dismissPrompt: NonNullable = (request) => diff --git a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts index c3e8e93407bb..d39099c1d460 100644 --- a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts +++ b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts @@ -2,20 +2,29 @@ import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '@orca/process-host' -import type { ProviderProcessTeardownVerdict } from '../provider-process/provider-process-teardown' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import { providerRequestTimeoutOf } from '../provider-process/provider-request-timeout' import { openCodexAppServerConnection } from './codex-app-server-connection' +import { codexTurnLifecycleRig } from './codex-structured-dispatch-test-support' const teardown = vi.hoisted(() => { - const state: { verdict: ProviderProcessTeardownVerdict; rootExits: () => void } = { - verdict: null, + const state: { verdict: DescendantTreeVerdict; rootExits: () => void } = { + verdict: 'unverifiable', rootExits: () => {} } return state }) -vi.mock('../provider-process/provider-process-teardown', () => ({ - terminateProviderProcessTree: vi.fn(async () => { - teardown.rootExits() - return teardown.verdict +vi.mock('../claude/claude-agent-sdk-exit-proof', () => ({ + createClaudeChildTreeReaper: () => ({ + capture: async () => {}, + refresh: async () => {}, + reap: async () => { + teardown.rootExits() + return teardown.verdict + }, + get treeVerdict() { + return teardown.verdict + } }) })) @@ -40,11 +49,59 @@ function stubChild() { } describe('Codex process-tree diagnostic after a forced close', () => { + it.each([undefined, 'turn-1'])( + 'bounds unanswered Stop %s and ignores its late reply after replacement', + async (turnId) => { + const rig = await codexTurnLifecycleRig() + const session = rig.adapter['sessions'].get('session-1')! + session.activeTurnIds?.add('turn-1') + vi.useFakeTimers() + const { child, spawnImpl } = stubChild() + const connection = await openCodexAppServerConnection( + { command: 'codex', args: [] }, + {}, + spawnImpl + ) + const original = session.connection + session.connection = connection + const stopping = rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + turnId, + stoppedAt: Date.now() - 4_900 + }) + const timedOut = expect(stopping).rejects.toThrow('exceeded 100ms') + await vi.advanceTimersByTimeAsync(100) + await timedOut + await expect(stopping.then(() => null, providerRequestTimeoutOf)).resolves.toEqual({ + method: 'turn/interrupt', + timeoutMs: 100 + }) + const replacement = { + ...session, + connection: original, + fence: 8, + abortedTurnIds: new Set() + } + rig.adapter['sessions'].set('session-1', replacement) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + child.stdout.write(`${JSON.stringify({ id: 2, result: {} })}\n`) + await vi.advanceTimersByTimeAsync(0) + expect(replacement.abortedTurnIds.size).toBe(0) + expect(warning).toHaveBeenCalled() + warning.mockRestore() + teardown.rootExits = () => child.emit('exit', 0, null) + const closing = connection.close() + await vi.advanceTimersByTimeAsync(10_000) + await closing + await rig.adapter.closeAll() + } + ) + it.each([ ['unverifiable', true], ['live', true], - ['exited', false], - [null, false] + ['exited', false] ] as const)('teardown observing %s reads unproven=%s', async (verdict, unproven) => { vi.useFakeTimers() teardown.verdict = verdict @@ -62,4 +119,27 @@ describe('Codex process-tree diagnostic after a forced close', () => { await expect(connection.close()).resolves.toBe(true) expect(connection.processTreeUnproven).toBe(unproven) }) + + it('reports a root exit that arrives after an unproven close', async () => { + vi.useFakeTimers() + teardown.verdict = 'unverifiable' + teardown.rootExits = () => {} + const { child, spawnImpl } = stubChild() + const onExit = vi.fn() + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit }, + spawnImpl + ) + const closing = connection.close() + await vi.advanceTimersByTimeAsync(10_000) + await expect(closing).resolves.toBe(false) + expect(onExit).not.toHaveBeenCalled() + child.emit('exit', 0, null) + await vi.advanceTimersByTimeAsync(0) + expect(onExit).toHaveBeenCalledTimes(1) + expect(onExit).toHaveBeenCalledWith(expect.any(Error), { expected: true }) + expect(connection.closeResult).toMatchObject({ root: 'exited', tree: 'unverifiable' }) + expect(connection.processTreeUnproven).toBe(true) + }) }) diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index c04a4820e743..d5f6f39c1d87 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -32,6 +32,9 @@ export type CodexAppServerConnection = { resumeReading?: () => void /** Resolves true only after the child emitted `exit` or `close`; false is unproven. */ close: () => Promise + /** The last close's separate root and workload observations. */ + readonly closeResult?: ProviderProcessCloseResult | null /** The root exited, but the forced kill of its process tree could not prove the tree gone. */ readonly processTreeUnproven?: boolean } +import type { ProviderProcessCloseResult } from '../provider-process/provider-process-close' diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts deleted file mode 100644 index 18150b4767f8..000000000000 --- a/src/main/codex/codex-app-server-connection.test.ts +++ /dev/null @@ -1,877 +0,0 @@ -import { FAKE_APP_SERVER } from './__fixtures__/codex-connection-app-server' -import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' -import type { EventEmitter } from 'node:events' -import { realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { PassThrough } from 'node:stream' -import { providerDiagnosticOf } from '../../shared/agent-session-failure' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProcessSpec, PipedProcessSpawner } from '@orca/process-host/process-spec' - -import { - isCodexAppServerRequestError, - openCodexAppServerConnection, - type CodexAppServerConnection, - type CodexAppServerConnectionHandlers -} from './codex-app-server-connection' -import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' -import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import { providerRequestTimeoutOf } from '../provider-process/provider-request-timeout' -import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' - -// close() waits out the supervisor's own stop before forcing the tree. -const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS - -const originalCodexHome = process.env.CODEX_HOME - -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - if (originalCodexHome === undefined) { - delete process.env.CODEX_HOME - } else { - process.env.CODEX_HOME = originalCodexHome - } -}) - -// A real child verifies spawning, environment and bidirectional JSONL traffic. -async function openFakeServer( - handlers: CodexAppServerConnectionHandlers = {}, - env?: Record, - envToDelete?: string[], - cwd?: string -): Promise { - return openCodexAppServerConnection( - { command: process.execPath, args: ['-e', FAKE_APP_SERVER], env, envToDelete, cwd }, - handlers - ) -} - -type StubChild = EventEmitter & { - stdout: PassThrough - stderr: PassThrough - stdin: PassThrough - pid: number - kill: ReturnType -} - -/** Full control over framing and death, which a real child cannot give. */ -function stubChild(options: { exitOnStdinEnd?: boolean } = {}): { - child: ReturnType - spawnImpl: PipedProcessSpawner - written: Record[] -} { - const child = createFakePipedChild(9_999_999) - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.stdin = new PassThrough() - // Keep the synthetic PID outside real process tables so teardown cannot target another process. - child.pid = 9_999_999 - child.kill = vi.fn() - const written: Record[] = [] - child.stdin.on('data', (chunk: Buffer) => { - for (const line of chunk.toString('utf8').split('\n')) { - if (line.trim()) { - written.push(JSON.parse(line) as Record) - } - } - }) - if (options.exitOnStdinEnd !== false) { - child.stdin.on('finish', () => child.emit('exit', 0, null)) - } - return { child, spawnImpl: () => child, written } -} - -function answerInitialize(child: StubChild): void { - child.stdin.once('data', () => { - child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) - }) -} - -/** Stream writes land a tick later, so the stderr tail is only complete here. */ -async function flushStreams(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -async function closeWithoutObservedExit(start: () => Promise, child: StubChild): Promise { - const forcedKill = new Promise((resolve) => { - child.kill.mockImplementation((signal) => (signal === 'SIGKILL' ? resolve() : undefined)) - }) - const closing = start() - await flushStreams() - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) - await forcedKill - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(1_000) - return closing -} - -function rejection(promise: Promise): Promise { - return promise.then( - () => { - throw new Error('expected the call to reject') - }, - (error: Error) => error - ) -} - -function commandCompletionFixture( - targetBytes: number, - itemId = 'item-large' -): { line: string; output: string } { - const frame = { - method: 'item/completed', - params: { - turnId: 'turn-large', - item: { id: itemId, type: 'commandExecution', aggregated_output: '' } - } - } - const emptyBytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') - const remaining = targetBytes - emptyBytes - if (remaining < 0) { - throw new Error(`target ${targetBytes} is smaller than fixture envelope ${emptyBytes}`) - } - const output = `${'\n'.repeat(Math.floor(remaining / 2))}${remaining % 2 ? 'x' : ''}` - frame.params.item.aggregated_output = output - const line = JSON.stringify(frame) - expect(Buffer.byteLength(line, 'utf8')).toBe(targetBytes) - return { line: `${line}\n`, output } -} - -function commandCompletionLine(targetBytes: number): string { - return commandCompletionFixture(targetBytes).line -} - -function responseLine(targetBytes: number, id: number): string { - const frame = { id, result: { data: '' } } - const emptyBytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') - frame.result.data = 'x'.repeat(targetBytes - emptyBytes) - const line = JSON.stringify(frame) - expect(Buffer.byteLength(line, 'utf8')).toBe(targetBytes) - return `${line}\n` -} - -describe('openCodexAppServerConnection', () => { - it.runIf(process.platform !== 'win32')( - 'has its supervisor close Codex by its stdin end, the way its own close does', - async () => { - const { child, spawnImpl } = stubChild() - const specs: ProcessSpec[] = [] - answerInitialize(child) - - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - (spec) => { - specs.push(spec) - return spawnImpl(spec) - } - ) - - expect( - JSON.parse( - Buffer.from(String(specs[0]?.env?.ORCA_PROVIDER_SUPERVISOR_SPEC), 'base64').toString() - ) - ).toMatchObject({ closeRequest: 'stdin-end' }) - await connection.close() - } - ) - - it('advertises the experimental API required for rollout-path resume', async () => { - const { child, spawnImpl, written } = stubChild() - answerInitialize(child) - - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - - expect(written[0]).toMatchObject({ - method: 'initialize', - params: { capabilities: { experimentalApi: true } } - }) - await connection.close() - }) - - it('reports the spawned pid before it sends the handshake', async () => { - const { child, spawnImpl, written } = stubChild() - answerInitialize(child) - const writtenAtSpawn: number[] = [] - - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onSpawned: async (pid) => { - writtenAtSpawn.push(written.length) - expect(pid).toBe(child.pid) - } - }, - spawnImpl - ) - - // The owner is durable before initialize, so a crash mid-handshake leaves it stoppable. - expect(writtenAtSpawn).toEqual([0]) - expect(written[0]).toMatchObject({ method: 'initialize' }) - await connection.close() - }) - - it('reaps the child and never handshakes when its spawn cannot be recorded', async () => { - const { spawnImpl, written } = stubChild() - - await expect( - openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onSpawned: async () => { - throw new Error('agent_session_checkpoint_stale') - } - }, - spawnImpl - ) - ).rejects.toThrow('agent_session_checkpoint_stale') - expect(written).toEqual([]) - }) - - it('completes the handshake and keeps the child alive across calls', async () => { - const notifications: { method: string; params: unknown }[] = [] - const connection = await openFakeServer({ - onNotification: (method, params) => notifications.push({ method, params }) - }) - - await connection.request('test/notify') - await connection.request('test/notify') - - expect(connection.pid).toBeGreaterThan(0) - expect(connection.closed).toBe(false) - expect(notifications).toHaveLength(2) - expect(notifications[0]).toEqual({ - method: 'turn/started', - params: { threadId: 'thread-1', turn: { id: 'turn-7' } } - }) - await connection.close() - expect(connection.closed).toBe(true) - }) - - it('applies the environment overlay after stripping inherited keys', async () => { - process.env.CODEX_HOME = '/tmp/inherited-home' - const pinned = await openFakeServer({}, { CODEX_HOME: '/tmp/pinned-home' }) - expect(await pinned.request('test/env')).toEqual({ codexHome: '/tmp/pinned-home' }) - await pinned.close() - - const stripped = await openFakeServer({}, undefined, ['CODEX_HOME']) - expect(await stripped.request('test/env')).toEqual({ codexHome: null }) - await stripped.close() - }) - - it('starts the provider in the resolved workspace directory', async () => { - const workspace = realpathSync(tmpdir()) - const connection = await openFakeServer({}, undefined, undefined, workspace) - - await expect(connection.request('test/cwd')).resolves.toEqual({ cwd: workspace }) - await connection.close() - }) - - it('routes a server request to the handler and writes the reply back', async () => { - const requests: { id: number | string; method: string }[] = [] - let resolveAnswered: (params: unknown) => void = () => {} - const answered = new Promise((resolve) => { - resolveAnswered = resolve - }) - const connection = await openFakeServer({ - onServerRequest: (request) => { - requests.push({ id: request.id, method: request.method }) - connection.respond(request.id, { decision: 'accept' }) - }, - onNotification: (method, params) => { - if (method === 'test/answered') { - resolveAnswered(params) - } - } - }) - - connection.notify('test/ask') - - expect(await answered).toEqual({ id: 99, result: { decision: 'accept' } }) - expect(requests).toEqual([{ id: 99, method: 'item/fileChange/requestApproval' }]) - await connection.close() - }) - - it('classifies a refusal apart from a missing method', async () => { - const connection = await openFakeServer() - - const refusal = await connection.request('test/refuse').catch((error: unknown) => error) - const missing = await connection.request('test/missing').catch((error: unknown) => error) - - expect(isCodexAppServerRequestError(refusal)).toBe(true) - expect((refusal as Error).message).toContain('bad params') - expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' }) - expect(providerDiagnosticOf(missing)).toBeUndefined() - expect(isCodexAppServerUnsupportedError(missing)).toBe(true) - expect(isCodexAppServerRequestError(missing)).toBe(false) - await connection.close() - }) - - it('reassembles a message split mid-character across chunks', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const notifications: unknown[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onNotification: (_method, params) => notifications.push(params) }, - spawnImpl - ) - - const payload = Buffer.from( - `${JSON.stringify({ method: 'item/agentMessage/delta', params: { delta: '日本語' } })}\n`, - 'utf8' - ) - const split = payload.indexOf(Buffer.from('日', 'utf8')) + 1 - child.stdout.write(payload.subarray(0, split)) - child.stdout.write(payload.subarray(split)) - await vi.waitFor(() => expect(notifications).toHaveLength(1)) - - expect(notifications[0]).toEqual({ delta: '日本語' }) - await connection.close() - }) - - it('surfaces valid but unclassified frames instead of dropping them', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const frames: { kind: string; payload: unknown }[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) }, - spawnImpl - ) - - child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`) - child.stdout.write(`${JSON.stringify({ id: null, error: { message: 'parse error' } })}\n`) - await vi.waitFor(() => expect(frames).toHaveLength(2)) - - expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'frame:unclassified']) - await connection.close() - }) - - it('logs a reply to a timed-out request instead of surfacing it as a frame', async () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { child, spawnImpl, written } = stubChild() - answerInitialize(child) - const frames: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onUnhandledFrame: (kind) => frames.push(kind) }, - spawnImpl - ) - - const slow = rejection(connection.request('turn/interrupt', undefined, { timeoutMs: 50 })) - await vi.advanceTimersByTimeAsync(60) - const timedOut = await slow - expect(timedOut.name).toBe('CodexAppServerTimeoutError') - expect(providerRequestTimeoutOf(timedOut)).toEqual({ method: 'turn/interrupt', timeoutMs: 50 }) - const id = Number(written.find((frame) => frame.method === 'turn/interrupt')?.id) - child.stdout.write(`${JSON.stringify({ id, result: {} })}\n`) - child.stdout.write(`${JSON.stringify({ id: 999, error: { message: 'no such request' } })}\n`) - await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(2)) - - expect(frames).toEqual([]) - expect(warn.mock.calls.map((call) => call[0])).toEqual([ - `[codex-app-server] late reply to turn/interrupt after timeout (id ${id})`, - '[codex-app-server] reply with no waiting request (id 999)' - ]) - expect(warn.mock.calls[1][1]).toBe('no such request') - await vi.advanceTimersByTimeAsync(0) - }) - - it('fails in-flight requests and reports an unexpected exit once', async () => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onExit: (error) => exits.push(error.message) }, - spawnImpl - ) - - const inFlight = rejection(connection.request('turn/start')) - child.stderr.write('codex crashed\n') - await flushStreams() - child.emit('exit', 1, null) - child.emit('close', 1, null) - - expect((await inFlight).message).toContain('codex crashed') - expect(exits).toHaveLength(1) - await connection.close() - }) - - it('classifies a CLI without the app-server subcommand as unsupported', async () => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - const opening = openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ).catch((error: unknown) => error) - - child.stderr.write("error: unrecognized subcommand 'app-server'\n") - await flushStreams() - child.emit('exit', 2, null) - child.emit('close', 2, null) - - expect(isCodexAppServerUnsupportedError(await opening)).toBe(true) - }) - - it('exposes an unproven handshake child for later cleanup', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - child.stdin.once('data', () => { - child.stdout.write( - `${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n` - ) - }) - const error = await closeWithoutObservedExit( - () => - rejection( - openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) - ), - child - ) - expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') - if (!isCodexAppServerHandshakeExitUnprovenError(error)) { - throw error - } - expect(error.connection).toBeDefined() - child.emit('exit', 1, null) - child.emit('close', 1, null) - await expect(error.connection.close()).resolves.toBe(true) - }) - - it('times out one request without ending the connection', async () => { - vi.useFakeTimers() - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - - const slow = rejection(connection.request('turn/start', undefined, { timeoutMs: 50 })) - await vi.advanceTimersByTimeAsync(60) - - expect((await slow).name).toBe('CodexAppServerTimeoutError') - expect(connection.closed).toBe(false) - await vi.advanceTimersByTimeAsync(0) - }) - - it('kills a child that ignores stdin EOF', async () => { - vi.useFakeTimers() - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - - const closing = connection.close() - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 500) - await closing - - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) - }) - - it('reports unproven close when forced termination did not produce an exit event', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - - await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) - expect(vi.getTimerCount()).toBe(0) - }, 10_000) - - it('shares one eventual exit proof across concurrent close callers', async () => { - vi.useFakeTimers() - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - child.kill.mockImplementation(() => { - setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10) - return true - }) - - const first = connection.close() - const second = connection.close() - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 2_600) - - await expect(Promise.all([first, second])).resolves.toEqual([true, true]) - expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL']) - }) - - it('allows a later close to observe exit after an unproven attempt', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - - await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) - child.emit('exit', 0, null) - - await expect(connection.close()).resolves.toBe(true) - }) - - it('kills the root again on a close after an unproven attempt', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - - await expect(closeWithoutObservedExit(() => connection.close(), child)).resolves.toBe(false) - const secondForcedKill = new Promise((resolve) => { - child.kill.mockImplementation((signal) => { - if (signal === 'SIGKILL') { - setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10) - resolve() - } - return true - }) - }) - - const second = connection.close() - await flushStreams().then(() => vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS)) - await secondForcedKill - await vi.advanceTimersByTimeAsync(10) - await expect(second).resolves.toBe(true) - expect(child.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL')).toHaveLength(2) - }) - - it.each([1_090_188, 2_900_090])( - 'accepts a realistic %i-byte escaped command completion and keeps processing', - async (frameBytes) => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const completed: unknown[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onNotification: (method, params) => { - if (method === 'item/completed') { - completed.push(params) - } - } - }, - spawnImpl - ) - - const line = Buffer.from(commandCompletionLine(frameBytes), 'utf8') - const split = Math.floor(line.length / 3) - child.stdout.write(line.subarray(0, split)) - child.stdout.write(line.subarray(split, split * 2)) - child.stdout.write(line.subarray(split * 2)) - child.stdout.write('{"method":"turn/completed","params":{"turn":{"id":"turn-large"}}}\n') - await vi.waitFor(() => expect(completed).toHaveLength(1)) - - expect( - (completed[0] as { item: { aggregated_output: string } }).item.aggregated_output.length - ).toBeGreaterThan(500_000) - expect(connection.closed).toBe(false) - await connection.close() - } - ) - - it('accepts two realistic large command completions without losing either payload', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const completed: { item: { id: string; aggregated_output: string } }[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onNotification: (method, params) => { - if (method === 'item/completed') { - completed.push(params as { item: { id: string; aggregated_output: string } }) - } - } - }, - spawnImpl - ) - const fixtures = [ - commandCompletionFixture(1_090_188, 'item-large-a'), - commandCompletionFixture(2_900_090, 'item-large-b') - ] - - child.stdout.write(fixtures[0]!.line) - child.stdout.write(fixtures[1]!.line) - await vi.waitFor(() => expect(completed).toHaveLength(2)) - - expect(completed.map((entry) => entry.item.id)).toEqual(['item-large-a', 'item-large-b']) - expect( - completed.map((entry) => Buffer.byteLength(entry.item.aggregated_output, 'utf8')) - ).toEqual(fixtures.map((fixture) => Buffer.byteLength(fixture.output, 'utf8'))) - expect(connection.closed).toBe(false) - await connection.close() - }) - - it('accepts a response beyond the daemon wire limit and keeps the provider alive', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - {}, - spawnImpl - ) - - const large = connection.request('thread/resume') - child.stdout.write(responseLine(16 * 1024 * 1024 + 1, 2)) - await expect(large).resolves.toMatchObject({ data: expect.any(String) }) - expect(child.kill).not.toHaveBeenCalled() - expect(connection.closed).toBe(false) - - const followup = connection.request('turn/start') - child.stdout.write('{"id":3,"result":{"turn":{"id":"turn-next"}}}\n') - await expect(followup).resolves.toEqual({ turn: { id: 'turn-next' } }) - await connection.close() - }) - - it('delivers a notification beyond the daemon wire limit whole, never as an oversized frame', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const frames: string[] = [] - const deltas: unknown[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onUnhandledFrame: (kind) => frames.push(kind), - onNotification: (method, params) => { - if (method === 'item/commandExecution/outputDelta') { - deltas.push(params) - } - } - }, - spawnImpl - ) - - const params = { threadId: 'thread-1', itemId: 'exec-1', delta: '' } - params.delta = 'x'.repeat(16 * 1024 * 1024 + 1) - child.stdout.write( - `${JSON.stringify({ method: 'item/commandExecution/outputDelta', params })}\n` - ) - - await vi.waitFor(() => expect(deltas).toEqual([params])) - expect(frames).toEqual([]) - expect(connection.closed).toBe(false) - await connection.close() - }) - - it('keeps malformed and non-object JSON non-fatal and processes the next record', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const frames: { kind: string; payload: unknown }[] = [] - const notifications: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }), - onNotification: (method) => notifications.push(method) - }, - spawnImpl - ) - - child.stdout.write('not json\n[]\n{"method":"turn/completed","params":{}}\n') - await vi.waitFor(() => expect(notifications).toEqual(['turn/completed'])) - - expect(frames).toEqual([ - { kind: 'frame:invalid-json', payload: 'not json' }, - { kind: 'frame:invalid-json', payload: '[]' } - ]) - expect(connection.closed).toBe(false) - await connection.close() - }) - - it('pauses between coalesced records and resumes the retained remainder', async () => { - const { child, spawnImpl } = stubChild() - answerInitialize(child) - const notifications: string[] = [] - let connection: CodexAppServerConnection - connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onNotification: (method) => { - notifications.push(method) - if (notifications.length === 1) { - connection.pauseReading?.() - } - } - }, - spawnImpl - ) - - child.stdout.write( - '{"method":"item/started","params":{}}\n{"method":"item/completed","params":{}}\n' - ) - await vi.waitFor(() => expect(notifications).toEqual(['item/started'])) - connection.resumeReading?.() - await vi.waitFor(() => expect(notifications).toEqual(['item/started', 'item/completed'])) - - await connection.close() - }) - - it.each([ - { - kind: 'notification', - frame: { method: 'turn/started', params: { turn: { id: 'turn-1' } } } - }, - { - kind: 'server request', - frame: { id: 41, method: 'item/fileChange/requestApproval', params: { itemId: 'item-1' } } - } - ])('surfaces a synchronous $kind handler failure as a terminal exit', async ({ frame }) => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: string[] = [] - const fail = (): never => { - throw new Error('structured sink failed') - } - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onNotification: fail, - onServerRequest: fail, - onExit: (error) => exits.push(error.message) - }, - spawnImpl - ) - const inFlight = rejection(connection.request('turn/start')) - child.stdout.write(`${JSON.stringify(frame)}\n`) - - expect((await inFlight).message).toContain('structured sink failed') - expect(exits).toHaveLength(0) - expect(connection.closed).toBe(true) - child.emit('exit', null, 'SIGKILL') - expect(exits).toEqual([expect.stringContaining('structured sink failed')]) - await connection.close() - }) - - it('reports one exit for a death that arrives through two listeners', async () => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onExit: (error) => exits.push(error.message) }, - spawnImpl - ) - - child.emit('error', new Error('provider transport failed')) - child.stderr.write('provider died\n') - await flushStreams() - child.emit('exit', null, 'SIGKILL') - child.emit('close', null, 'SIGKILL') - - expect(exits).toHaveLength(1) - expect(exits[0]).toContain('provider transport failed') - await connection.close() - }) - - it('does not report recovery for a handler failure until child exit is observed', async () => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { - onNotification: () => { - throw new Error('structured sink failed') - }, - onExit: (error) => exits.push(error.message) - }, - spawnImpl - ) - - const inFlight = rejection(connection.request('turn/start')) - child.stdout.write('{"method":"turn/started","params":{}}\n') - await flushStreams() - - expect(exits).toHaveLength(0) - expect((await inFlight).message).toContain('structured sink failed') - - child.emit('exit', null, 'SIGKILL') - expect(exits).toHaveLength(1) - await connection.close() - }) - - it('treats a broken stdin pipe as the end of the transport', async () => { - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: string[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onExit: (error) => exits.push(error.message) }, - spawnImpl - ) - const inFlight = rejection(connection.request('turn/start')) - child.stdin.emit('error', new Error('write EPIPE')) - - expect((await inFlight).message).toContain('EPIPE') - expect(exits).toHaveLength(0) - expect(connection.closed).toBe(true) - child.emit('exit', null, 'SIGKILL') - expect(exits).toHaveLength(1) - expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE') - await connection.close() - }) - - it('reports a graceful close as expected when stdin breaks during the reap', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) - answerInitialize(child) - const exits: (boolean | undefined)[] = [] - const connection = await openCodexAppServerConnection( - { command: 'codex', args: ['app-server'] }, - { onExit: (_error, exit) => exits.push(exit?.expected) }, - spawnImpl - ) - child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE'))) - const forcedKill = new Promise((resolve) => { - child.kill.mockImplementation((signal) => { - child.emit('exit', null, 'SIGKILL') - if (signal === 'SIGKILL') { - resolve() - } - return true - }) - }) - - const inFlight = rejection(connection.request('turn/start')) - const closing = connection.close() - await flushStreams() - await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS) - await forcedKill - await vi.advanceTimersByTimeAsync(0) - await expect(closing).resolves.toBe(true) - - expect((await inFlight).message).toContain('EPIPE') - expect(exits).toEqual([true]) - expect(vi.getTimerCount()).toBe(0) - }) -}) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index e4e4d81aaac2..377ef1102a59 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,6 +1,7 @@ import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import { spawnProcess } from '@orca/process-host' import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process' +import { createClaudeChildTreeReaper } from '../claude/claude-agent-sdk-exit-proof' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { withMissingProviderExecutable } from '../provider-process/provider-executable-missing' @@ -47,9 +48,13 @@ async function connectCodexAppServer( site: 'codex-app-server-teardown' }) const { child } = managed + const tree = createClaudeChildTreeReaper(child, { + exited: () => managed.rootVerdict === 'exited' + }) let nextRequestId = 1 let closing = false + let closesInFlight = 0 let exitReported = false // Several listeners can report one failure; preserve its first, specific cause. let terminalError: Error | null = null @@ -74,7 +79,7 @@ async function connectCodexAppServer( terminalError = buildExitError(cause) dispatcher.failPending(terminalError) } - if (managed.rootVerdict === 'exited' && !exitReported) { + if (managed.rootVerdict === 'exited' && closesInFlight === 0 && !exitReported) { exitReported = true handlers.onExit?.(terminalError, { expected: closing }) } @@ -193,10 +198,23 @@ async function connectCodexAppServer( function close(): Promise { closing ||= managed.rootVerdict !== 'exited' - return managed.close().then((result) => { - dispatcher.failPending(new Error('codex app-server connection closed')) - return result.root === 'exited' - }) + closesInFlight += 1 + return managed + .close(tree) + .then((result) => { + if (result.root === 'exited' && !exitReported) { + exitReported = true + handlers.onExit?.(terminalError ?? buildExitError(), { expected: closing }) + } + dispatcher.failPending(new Error('codex app-server connection closed')) + return result.root === 'exited' + }) + .finally(() => { + closesInFlight -= 1 + if (managed.rootVerdict === 'exited') { + handleUnexpectedEnd() + } + }) } const connection: CodexAppServerConnection = { @@ -207,10 +225,14 @@ async function connectCodexAppServer( return closing || managed.rootVerdict === 'exited' || terminalError !== null }, get processTreeUnproven() { - const tree = managed.lastCloseResult?.tree - return ( - managed.lastCloseResult?.root === 'exited' && (tree === 'unverifiable' || tree === 'live') - ) + const result = connection.closeResult + return result?.root === 'exited' && result.tree !== 'exited' + }, + get closeResult() { + const result = managed.lastCloseResult + return result && managed.rootVerdict === 'exited' + ? { ...result, root: 'exited' as const } + : result }, request, notify, diff --git a/src/main/codex/codex-app-server-teardown.integration.test.ts b/src/main/codex/codex-app-server-teardown.integration.test.ts index 5932c5cc0dc7..483cfc791193 100644 --- a/src/main/codex/codex-app-server-teardown.integration.test.ts +++ b/src/main/codex/codex-app-server-teardown.integration.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' import { openCodexAppServerConnection, @@ -7,6 +8,35 @@ import { const ITERATIONS = 40 +const DETACHED_GRANDCHILD_APP_SERVER = String.raw` + const { spawn } = require('node:child_process') + const readline = require('node:readline') + const grandchild = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 60000)"], { + detached: true, + stdio: 'ignore' + }) + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') send({ id: message.id, result: {} }) + if (message.method === 'initialized') { + send({ method: 'test/grandchild', params: { provider: process.pid, grandchild: grandchild.pid } }) + } + }) + process.on('SIGTERM', () => {}) + setInterval(() => {}, 60000) +` + +function processIdentity(pid: number): string | null { + try { + return execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { + encoding: 'utf8' + }).trim() + } catch { + return null + } +} + const FORCE_KILL_APP_SERVER = String.raw` const { spawn } = require('node:child_process') const readline = require('node:readline') @@ -88,42 +118,50 @@ async function cleanupServer(server: RunningServer): Promise { } describe.runIf(process.platform !== 'win32')('Codex app-server process teardown', () => { - it('reaps the forced-close descendant in 40 consecutive launches', async () => { - const running: RunningServer[] = [] + it('reaps a grandchild in its own session after an unresponsive provider root', async () => { + const observed = Promise.withResolvers<{ provider: number; grandchild: number }>() + const connection = await openCodexAppServerConnection( + { command: process.execPath, args: ['-e', DETACHED_GRANDCHILD_APP_SERVER] }, + { + onNotification: (method, params) => { + if ( + method === 'test/grandchild' && + typeof params === 'object' && + params !== null && + 'provider' in params && + 'grandchild' in params && + typeof params.provider === 'number' && + typeof params.grandchild === 'number' + ) { + observed.resolve({ provider: params.provider, grandchild: params.grandchild }) + } + } + } + ) + const pids = await observed.promise + const identities = [connection.pid, pids.provider, pids.grandchild] + .filter((pid): pid is number => typeof pid === 'number') + .map((pid) => ({ pid, identity: processIdentity(pid) })) try { - for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { - running.push(await openServer(iteration)) + expect(processExists(pids.grandchild)).toBe(true) + const rootExited = await connection.close() + expect(rootExited).toBe(true) + if (connection.closeResult?.tree === 'exited') { + await vi.waitFor(() => expect(processExists(pids.grandchild)).toBe(false)) + expect(connection.closeResult).toMatchObject({ root: 'exited', tree: 'exited' }) + } else { + expect(connection.closeResult?.tree).not.toBe('exited') + expect(connection.processTreeUnproven).toBe(true) } - expect(running.every(({ descendantPid }) => processExists(descendantPid))).toBe(true) - - const closed = await Promise.all(running.map(({ connection }) => connection.close())) - - expect(closed).toEqual(Array.from({ length: ITERATIONS }, () => true)) - expect(running.filter(({ descendantPid }) => processExists(descendantPid))).toEqual([]) } finally { - for (const server of running) { - await cleanupServer(server) - } - } - }, 30_000) - - it.concurrent.for(['normal', 'signal'] as const)( - 'reaps provider descendants before relaying a %s root exit in 40 consecutive launches', - { timeout: 60_000 }, - async (exitMode, { expect }) => { - for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { - const server = await openServer(iteration, exitMode) - try { - await server.exit - expect(processExists(server.supervisorPid)).toBe(false) - expect(processExists(server.descendantPid)).toBe(false) - await expect(server.connection.close()).resolves.toBe(true) - } finally { - await cleanupServer(server) + await connection.close().catch(() => false) + for (const { pid, identity } of identities) { + if (identity && processIdentity(pid) === identity) { + process.kill(pid, 'SIGKILL') } } } - ) + }, 30_000) it.concurrent('does not settle a stdin-close/root-exit race before the descendant is reaped', async ({ expect diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index c54f2565978b..5f73cfd2e163 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionChildWorkProducer } from '../native-chat/agent-session-wire/structured-agent-session-subagent-rows' import type { AgentSessionBackgroundTask, @@ -153,9 +154,9 @@ export class CodexBackgroundTaskTracker { return this.refresh() } - clear(): boolean { + clear(processExit?: StructuredAgentSessionEndedEvent['processExit']): boolean { + this.childWork.clear(this.commands.clear(), processExit) this.executions.clear() - this.childWork.clear(this.commands.clear()) return this.refresh() } diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts deleted file mode 100644 index dce4e3afe850..000000000000 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ /dev/null @@ -1,627 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' -import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' -import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' -import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' -import { - agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState -} from '../../shared/agent-status-child-work-display' -import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' -import { createAgentStatusStore } from '../../shared/agent-status-store' -import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' -import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' - -const PRIMARY = 'thread-parent' -const PARENT_TURN = 'turn-parent' -const CHILD = 'thread-child' -const parent = makeStructuredAgentStatusSubject( - { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - 'session-1' -) - -function turn( - method: 'turn/started' | 'turn/completed', - threadId: string, - turnId: string, - status = 'completed' -): CodexBackgroundTaskEvent { - return { method, threadId, params: { threadId, turn: { id: turnId, status } } } -} - -function spawned( - child = CHILD, - reporter = PRIMARY, - name = 'audit_build' -): CodexBackgroundTaskEvent { - return { - method: 'item/started', - threadId: reporter, - params: { - threadId: reporter, - turnId: PARENT_TURN, - item: { - type: 'subAgentActivity', - id: `activity-${child}`, - kind: 'started', - agentThreadId: child, - agentPath: `/root/${name}` - } - } - } -} - -function item( - method: 'item/started' | 'item/completed', - threadId: string, - turnId: string, - fields: Record -): CodexBackgroundTaskEvent { - return { method, threadId, params: { threadId, turnId, item: fields } } -} - -function shell(id: string, command: string, status = 'inProgress', source = 'agent') { - return { type: 'commandExecution', id, command, source, status } -} - -function harness() { - const tracker = new CodexBackgroundTaskTracker(PRIMARY) - const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) - expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() - let minted = 0 - const admission = createAgentChildWorkAdmission(store, { - mintChildWorkId: () => `child-${++minted}` - }) - let clock = 1_000 - const log: AgentChildWorkEvidence[][] = [] - const send = (...events: CodexBackgroundTaskEvent[]): void => { - for (const event of events) { - tracker.observe(event) - clock += 10 - const evidence = tracker.drainChildWorkEvidence(clock) - log.push(evidence) - reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) - } - } - const records = (): AgentChildWorkRecord[] => store.getChildren(parent) - const byKind = (kind: AgentChildWorkRecord['kind']) => - records().filter((record) => record.kind === kind) - const display = (childWorkId: string) => { - const children = records() - const views = projectAgentChildWorkViews( - children, - children.flatMap((child) => store.getAliasesForChild(child.childWorkId)) - ) - const view = views.find((candidate) => candidate.id === childWorkId) - return view && deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)) - } - return { tracker, store, send, records, byKind, display, log } -} - -function runningChild() { - const run = harness() - run.send(turn('turn/started', PRIMARY, PARENT_TURN), spawned(), turn('turn/started', CHILD, 'c1')) - return run -} - -describe('Codex child-work evidence', () => { - it('records a spawned child by its thread, with its turn as the run', () => { - const { records, store, log, send } = runningChild() - // Codex delivers the announcement a second time, on `item/completed`. - send({ ...spawned(), method: 'item/completed' }) - expect(records()).toEqual([ - expect.objectContaining({ - kind: 'agent', - membership: 'live', - state: 'working', - residency: 'background', - description: 'audit_build', - invocation: { invocationId: 'c1', generation: 1 }, - stoppable: true - }) - ]) - const aliases = store.getAliasesForChild(records()[0]!.childWorkId) - expect(aliases.map(({ aliasKind, alias }) => [aliasKind, alias])).toEqual([ - ['thread_id', CHILD], - ['turn_id', 'c1'] - ]) - expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1) - }) - - it('keeps usage that arrives before the child is announced, and never credits the parent its own', () => { - const { send, byKind } = harness() - const usage = (threadId: string, totalTokens: number): CodexBackgroundTaskEvent => ({ - method: 'thread/tokenUsage/updated', - threadId, - params: { threadId, tokenUsage: { total: { totalTokens } } } - }) - send(turn('turn/started', PRIMARY, PARENT_TURN), usage(PRIMARY, 9_000), usage(CHILD, 1_500)) - expect(byKind('agent')).toEqual([]) - send(spawned(), turn('turn/started', CHILD, 'c1')) - expect(byKind('agent')).toEqual([expect.objectContaining({ totalTokens: 1_500 })]) - }) - - it('makes no record for a child whose turn began before its announcement, until it lands', () => { - const { send, records } = harness() - send(turn('turn/started', PRIMARY, PARENT_TURN), turn('turn/started', CHILD, 'c1')) - expect(records()).toEqual([]) - send(spawned()) - expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) - }) - - it.each([ - ['completed', 'succeeded'], - ['interrupted', 'cancelled'], - ['failed', 'failed'], - ['somethingNew', 'unknown'] - ])('settles a child whose own turn ended %s as %s', (status, outcome) => { - const { send, tracker, records } = runningChild() - send(turn('turn/completed', CHILD, 'c1', status)) - expect(records()).toEqual([ - expect.objectContaining({ membership: 'settled', state: 'done', outcome }) - ]) - expect(tracker.state).toBeNull() - }) - - const childError = (turnId: string, willRetry: boolean): CodexBackgroundTaskEvent => ({ - method: 'error', - threadId: CHILD, - params: { threadId: CHILD, turnId, willRetry, error: { message: 'boom' } } - }) - const childClosed: CodexBackgroundTaskEvent = { - method: 'thread/closed', - threadId: CHILD, - params: { threadId: CHILD } - } - - it('keeps a child working through an error Codex will not retry, and settles it on the failed turn/completed that follows', () => { - const { send, tracker, records } = runningChild() - send(childError('c1', false)) - expect(records()).toEqual([expect.objectContaining({ membership: 'live' })]) - expect(tracker.state?.tasks).toHaveLength(1) - send(turn('turn/completed', CHILD, 'c1', 'failed')) - expect(records()).toEqual([ - expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'failed' }) - ]) - expect(tracker.state).toBeNull() - }) - - it('settles a working child whose thread closed with no turn/completed, in the strip and the record together', () => { - const { send, tracker, records } = runningChild() - expect(tracker.state?.tasks).toHaveLength(1) - send(childClosed) - expect(records()).toEqual([ - expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'unknown' }) - ]) - expect(tracker.state).toBeNull() - // A late turn outcome can refine an unknown thread-close result. - send(turn('turn/completed', CHILD, 'c1', 'completed')) - expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) - send(turn('turn/completed', CHILD, 'c1', 'failed')) - expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) - }) - - it('keeps a child working through a retried error and a systemError status: its turn runs on', () => { - const { send, tracker, records } = runningChild() - send(childError('c1', true), { - method: 'thread/status/changed', - threadId: CHILD, - params: { threadId: CHILD, status: { type: 'systemError' } } - }) - expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) - expect(tracker.state?.tasks).toHaveLength(1) - }) - - it('never settles a child on its PARENT turn ending: children outlive the turn', () => { - const { send, records } = runningChild() - send(turn('turn/completed', PRIMARY, PARENT_TURN)) - expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) - }) - - it('reopens the same record for a follow-up turn on a finished child, as a new run', () => { - const { send, records } = runningChild() - send(turn('turn/completed', CHILD, 'c1')) - const [finished] = records() - send(turn('turn/started', CHILD, 'c2')) - expect(records()).toEqual([ - expect.objectContaining({ - childWorkId: finished!.childWorkId, - membership: 'live', - state: 'working', - invocation: { invocationId: 'c2', generation: 2 }, - previousInvocations: [ - expect.objectContaining({ - fence: { invocationId: 'c1', generation: 1 }, - outcome: 'succeeded' - }) - ] - }) - ]) - send(turn('turn/completed', CHILD, 'c1', 'failed')) - expect(records()).toEqual([ - expect.objectContaining({ - membership: 'live', - invocation: { invocationId: 'c2', generation: 2 } - }) - ]) - send(turn('turn/completed', CHILD, 'c2', 'interrupted')) - expect(records()).toEqual([ - expect.objectContaining({ - childWorkId: finished!.childWorkId, - membership: 'settled', - outcome: 'cancelled' - }) - ]) - }) - - it('says which tool the child has open, the way a CLI row names a Codex shell', () => { - const { send, byKind } = runningChild() - send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) - expect(byKind('agent')[0]?.operation).toEqual({ - toolName: 'Bash', - input: 'npm test', - basis: 'open', - observedAt: 1_040 - }) - send( - item('item/started', CHILD, 'c1', { - type: 'mcpToolCall', - id: 'mcp-1', - server: 'github', - tool: 'search_issues', - arguments: { query: 'flaky' }, - status: 'inProgress' - }) - ) - expect(byKind('agent')[0]?.operation).toMatchObject({ - toolName: 'mcp__github__search_issues', - input: 'flaky' - }) - // Reveal the older call again without changing its opening time. - send( - item('item/completed', CHILD, 'c1', { type: 'mcpToolCall', id: 'mcp-1', status: 'completed' }) - ) - expect(byKind('agent')[0]?.operation).toEqual({ - toolName: 'Bash', - input: 'npm test', - basis: 'open', - observedAt: 1_040 - }) - send(item('item/completed', CHILD, 'c1', shell('cmd-1', 'npm test', 'completed'))) - expect(byKind('agent')[0]?.operation).toBeUndefined() - }) - - it('names a unified-exec shell as the open call until its process exits', () => { - const { send, byKind } = runningChild() - send( - item( - 'item/started', - CHILD, - 'c1', - shell('exec-1', 'npm test', 'inProgress', 'unifiedExecStartup') - ) - ) - expect(byKind('agent')[0]?.operation).toMatchObject({ toolName: 'Bash', input: 'npm test' }) - send( - item( - 'item/completed', - CHILD, - 'c1', - shell('exec-1', 'npm test', 'completed', 'unifiedExecStartup') - ) - ) - expect(byKind('agent')[0]?.operation).toBeUndefined() - // An approved command starts on the approval path and completes from unified exec. - send(item('item/started', CHILD, 'c1', shell('exec-2', 'touch ~/marker'))) - expect(byKind('agent')[0]?.operation).toMatchObject({ - toolName: 'Bash', - input: 'touch ~/marker' - }) - send( - item( - 'item/completed', - CHILD, - 'c1', - shell('exec-2', 'touch ~/marker', 'completed', 'unifiedExecStartup') - ) - ) - expect(byKind('agent')[0]?.operation).toBeUndefined() - }) - - it("never carries a run's open call into the next run when its ending was lost", () => { - const { send, byKind } = runningChild() - send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) - send(turn('turn/started', CHILD, 'c2')) - expect(byKind('agent')[0]).toMatchObject({ invocation: { invocationId: 'c2', generation: 2 } }) - expect(byKind('agent')[0]?.operation).toBeUndefined() - }) - - it('keeps what the child said last, and its usage, through to how it ended', () => { - const { send, byKind } = runningChild() - send( - item('item/completed', CHILD, 'c1', { - type: 'agentMessage', - id: 'msg-1', - text: 'Two tests\nflake on CI' - }), - { - method: 'thread/tokenUsage/updated', - threadId: CHILD, - params: { threadId: CHILD, tokenUsage: { total: { totalTokens: 4_200 } } } - } - ) - expect(byKind('agent')[0]).toMatchObject({ - lastMessage: 'Two tests flake on CI', - totalTokens: 4_200 - }) - send(turn('turn/completed', CHILD, 'c1')) - expect(byKind('agent')[0]).toMatchObject({ - membership: 'settled', - outcome: 'succeeded', - lastMessage: 'Two tests flake on CI', - totalTokens: 4_200 - }) - send(turn('turn/started', CHILD, 'c2')) - expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') - }) - - it('files a message whose frame names no turn under the run that said it, never the next', () => { - const { send, byKind } = runningChild() - send({ - method: 'item/completed', - threadId: CHILD, - params: { threadId: CHILD, item: { type: 'agentMessage', id: 'msg-1', text: 'Done' } } - }) - expect(byKind('agent')[0]?.lastMessage).toBe('Done') - send(turn('turn/completed', CHILD, 'c1'), turn('turn/started', CHILD, 'c2')) - send(turn('turn/completed', CHILD, 'c2')) - expect(byKind('agent')[0]).toMatchObject({ outcome: 'succeeded' }) - expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') - }) - - it('reads a child waiting on the user from its own thread status', () => { - const { send, byKind } = runningChild() - const status = (status: unknown): CodexBackgroundTaskEvent => ({ - method: 'thread/status/changed', - threadId: CHILD, - params: { threadId: CHILD, status } - }) - send(status({ type: 'active', activeFlags: ['waitingOnApproval'] })) - expect(byKind('agent')[0]?.state).toBe('waiting') - send(status({ type: 'active', activeFlags: [] })) - expect(byKind('agent')[0]?.state).toBe('working') - send(status({ type: 'active', activeFlags: ['waitingOnUserInput'] })) - send(turn('turn/completed', CHILD, 'c1')) - send(turn('turn/started', CHILD, 'c2')) - expect(byKind('agent')[0]?.state).toBe('working') - }) - - it('records a command from its start until its process exits, then removes it', () => { - const { send, byKind, display } = runningChild() - send( - item( - 'item/started', - CHILD, - 'c1', - shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') - ) - ) - const [agent] = byKind('agent') - expect(agent?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) - expect(byKind('command')).toEqual([ - expect.objectContaining({ - membership: 'live', - description: 'npm run dev', - residency: 'foreground', - parentChildWorkId: agent!.childWorkId, - firstObservedAt: 1_040 - }) - ]) - send(turn('turn/completed', CHILD, 'c1')) - expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) - expect(byKind('command')).toEqual([ - expect.objectContaining({ - membership: 'live', - residency: 'background', - firstObservedAt: 1_040 - }) - ]) - expect(display(agent!.childWorkId)).toBe('monitoring') - send( - item('item/completed', CHILD, 'c1', { - ...shell('exec-1', 'npm run dev', 'completed', 'unifiedExecStartup'), - exitCode: 1 - }) - ) - expect(byKind('command')).toEqual([]) - expect(display(agent!.childWorkId)).toBe('done') - }) - - it('records an approved command while it runs, whatever source Codex starts it with', () => { - const { send, tracker, byKind, display } = runningChild() - // The approval path starts the item as `agent`; unified exec reports its exit. - send(item('item/started', CHILD, 'c1', shell('exec-2', 'npm run dev'))) - const [agent] = byKind('agent') - expect(byKind('command')).toEqual([ - expect.objectContaining({ membership: 'live', parentChildWorkId: agent!.childWorkId }) - ]) - send(turn('turn/completed', CHILD, 'c1')) - expect(display(agent!.childWorkId)).toBe('monitoring') - expect(tracker.state?.tasks).toEqual([expect.objectContaining({ kind: 'command' })]) - send( - item( - 'item/completed', - CHILD, - 'c1', - shell('exec-2', 'npm run dev', 'completed', 'unifiedExecStartup') - ) - ) - expect(byKind('command')).toEqual([]) - expect(display(agent!.childWorkId)).toBe('done') - expect(tracker.state).toBeNull() - }) - - it("removes a closed thread's running commands: Codex stops them and never reports their exit", () => { - const { send, tracker, byKind, display } = runningChild() - send( - item( - 'item/started', - CHILD, - 'c1', - shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') - ), - turn('turn/completed', CHILD, 'c1'), - turn('turn/completed', PRIMARY, PARENT_TURN) - ) - const [agent] = byKind('agent') - expect(display(agent!.childWorkId)).toBe('monitoring') - send({ method: 'thread/closed', threadId: CHILD, params: { threadId: CHILD } }) - expect(byKind('command')).toEqual([]) - expect(display(agent!.childWorkId)).toBe('done') - expect(tracker.state).toBeNull() - send( - item( - 'item/completed', - CHILD, - 'c1', - shell('exec-1', 'npm run dev', 'completed', 'unifiedExecStartup') - ) - ) - expect(byKind('command')).toEqual([]) - }) - - it("names the owner of a command launched before the host held its child's record", () => { - const { send, byKind } = harness() - send( - turn('turn/started', PRIMARY, PARENT_TURN), - turn('turn/started', CHILD, 'c1'), - item( - 'item/started', - CHILD, - 'c1', - shell('exec-1', 'tail -f log', 'inProgress', 'unifiedExecStartup') - ) - ) - expect(byKind('command')).toEqual([ - expect.not.objectContaining({ parentChildWorkId: expect.anything() }) - ]) - send(spawned()) - expect(byKind('command')).toEqual([ - expect.objectContaining({ - description: 'tail -f log', - parentChildWorkId: byKind('agent')[0]?.childWorkId - }) - ]) - }) - - it("records the session's own command with no owner from its start", () => { - const { send, byKind } = harness() - send( - turn('turn/started', PRIMARY, PARENT_TURN), - item( - 'item/started', - PRIMARY, - PARENT_TURN, - shell('exec-9', 'sleep 90', 'inProgress', 'unifiedExecStartup') - ) - ) - expect(byKind('command')).toEqual([ - expect.objectContaining({ membership: 'live', description: 'sleep 90' }) - ]) - expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') - send(turn('turn/completed', PRIMARY, PARENT_TURN)) - expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) - }) - - it('leaves no record behind a finished shell', () => { - const { send, byKind, records, log } = runningChild() - send(turn('turn/completed', CHILD, 'c1')) - const before = log.length - for (let index = 0; index < 40; index += 1) { - const id = `exec-${index}` - send( - item('item/started', PRIMARY, PARENT_TURN, { - ...shell(id, 'rg foo', 'inProgress', 'unifiedExecStartup'), - durationMs: 0 - }) - ) - expect(byKind('command')).toEqual([expect.objectContaining({ description: 'rg foo' })]) - send( - item('item/completed', PRIMARY, PARENT_TURN, { - ...shell(id, 'rg foo', 'completed', 'unifiedExecStartup'), - exitCode: 0 - }) - ) - expect(byKind('command')).toEqual([]) - } - send(turn('turn/completed', PRIMARY, PARENT_TURN)) - expect(records()).toEqual([ - expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'succeeded' }) - ]) - expect( - log - .slice(before) - .flat() - .map((edge) => edge.type) - ).toEqual(Array.from({ length: 40 }, () => ['live', 'removed']).flat()) - }) - - it('names the child that spawned a nested child as its owner', () => { - const { send, byKind } = runningChild() - send( - spawned('thread-grandchild', CHILD, 'lint'), - turn('turn/started', 'thread-grandchild', 'g1') - ) - const nested = byKind('agent').find((record) => record.description === 'lint') - const owner = byKind('agent').find((record) => record.description === 'audit_build') - expect(nested?.parentChildWorkId).toBe(owner?.childWorkId) - }) - - it('holds no evidence for a session with nowhere to deliver it', () => { - const tracker = new CodexBackgroundTaskTracker(PRIMARY) - tracker.observe(spawned()) - tracker.observe(turn('turn/started', CHILD, 'c1')) - tracker.publishChildWork() - expect(tracker.drainChildWorkEvidence(1)).toEqual([]) - }) - - it('settles every live child with no reported outcome, and removes every command, when the provider session ends', () => { - const { send, tracker, records, store } = runningChild() - send( - item( - 'item/started', - PRIMARY, - PARENT_TURN, - shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') - ), - turn('turn/completed', PRIMARY, PARENT_TURN) - ) - expect(records()).toHaveLength(2) - tracker.clear() - const evidence = tracker.drainChildWorkEvidence(9_000) - expect(evidence).toEqual([ - { - type: 'removed', - observedAt: 9_000, - handle: { idKind: 'task_id', id: 'codex-command:primary:exec-1' } - }, - { type: 'session-ended', observedAt: 9_000 } - ]) - reconcileAgentChildWorkEvidence({ - store, - admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => 'unused' }), - parent, - provider: 'codex', - evidence - }) - expect(records()).toEqual([ - expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'unknown' }) - ]) - }) -}) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 31ce8b909d31..aa101f7e7c9b 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AgentChildWorkEvidence, AgentChildWorkLiveObservation @@ -97,7 +98,23 @@ export class CodexChildWorkEvidence { } } - clear(commands: readonly CodexBackgroundCommandChange[]): void { + clear( + commands: readonly CodexBackgroundCommandChange[], + processExit?: StructuredAgentSessionEndedEvent['processExit'] + ): void { + if (processExit?.root === 'exited' && processExit.workload === 'exited') { + for (const child of this.executions.workingChildren()) { + const { agentThreadId, execution } = child + if (execution) { + this.pending.push((observedAt) => ({ + type: 'ended', + observedAt, + handle: { idKind: 'thread_id', id: agentThreadId, runId: execution.turnId }, + outcome: 'cancelled' + })) + } + } + } this.facts.clear() this.queueCommands(commands) this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) diff --git a/src/main/codex/codex-request-end-wait.ts b/src/main/codex/codex-request-end-wait.ts new file mode 100644 index 000000000000..b51cd5e7e58e --- /dev/null +++ b/src/main/codex/codex-request-end-wait.ts @@ -0,0 +1,56 @@ +import type { CodexSession } from './codex-structured-session-state' +import { codexStopTarget } from './codex-structured-turn-open-wait' + +export const CODEX_STOP_GRACE_MS = 5_000 +const waiters = new WeakMap void>>() + +function requestInFlight(session: CodexSession): boolean { + return !session.ended && (session.dispatchPending === true || codexStopTarget(session) !== null) +} + +/** Re-reads the provider's turns after each settlement, within the original Stop's budget. */ +export function codexStoppedRequestEndWait( + sessions: ReadonlyMap +): (sessionId: string, stoppedAt: number) => Promise { + return async (sessionId, stoppedAt) => { + const session = sessions.get(sessionId) + if (!session || !requestInFlight(session)) { + return + } + let elapsed = false + let timer: ReturnType | undefined + const budget = new Promise((resolve) => { + timer = setTimeout( + () => { + elapsed = true + resolve() + }, + Math.max(0, stoppedAt + CODEX_STOP_GRACE_MS - Date.now()) + ) + timer.unref?.() + }) + try { + while (!elapsed && requestInFlight(session)) { + const waiting = waiters.get(session) ?? new Set<() => void>() + waiters.set(session, waiting) + const next = Promise.withResolvers() + waiting.add(next.resolve) + try { + await Promise.race([next.promise, budget]) + } finally { + waiting.delete(next.resolve) + } + } + } finally { + clearTimeout(timer) + } + } +} + +export function settleCodexRequestEndWaiters(session: CodexSession): void { + const waiting = waiters.get(session) + waiters.delete(session) + for (const wake of waiting ?? []) { + wake() + } +} diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index c6eb0fbdf698..6c0841717c2d 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -13,6 +13,7 @@ import { import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' import { codexStopTarget, interruptOpeningCodexTurn } from './codex-structured-turn-open-wait' +import { CODEX_STOP_GRACE_MS } from './codex-request-end-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -40,12 +41,17 @@ async function cancelCodexConversation( session: CodexSession ): Promise { const { request, sessions, requestTimeoutMs } = input + const stoppedAt = request.stoppedAt ?? Date.now() + const generation = session.acquisitionGeneration const liveTurnId = request.resolveLiveTurnId?.() ?? null // A command's journal turn can exist before its interruptible provider turn. const target = liveTurnId === null ? codexStopTarget(session) : { turnId: liveTurnId } // Opening waits can outlive their session; recheck ownership after awaiting. const current = (): boolean => - sessions.get(request.sessionId) === session && !session.ended && session.fence === request.fence + sessions.get(request.sessionId) === session && + !session.ended && + session.fence === request.fence && + session.acquisitionGeneration === generation if (!current()) { return { cancelled: false } } @@ -60,12 +66,18 @@ async function cancelCodexConversation( return { cancelled: false } } const interrupt = () => - interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs, stoppedAt }) if (!('opening' in target)) { return stoppedTurn(await interrupt(), journalTurnId) } return stoppedTurn( - await interruptOpeningCodexTurn(session, turnId, interrupt, current), + await interruptOpeningCodexTurn( + session, + turnId, + interrupt, + current, + stoppedAt + CODEX_STOP_GRACE_MS + ), journalTurnId ) } @@ -88,7 +100,13 @@ export async function cancelCodexStructuredTurn(input: { } if (!prompt) { return stoppedTurn( - await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + await interruptCodexTurn({ + session, + threadId: session.threadId, + turnId, + requestTimeoutMs, + stoppedAt: request.stoppedAt ?? Date.now() + }), requestedTurnId ) } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 8e8329fa226d..a36124db1659 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -35,13 +35,10 @@ import { deliverCodexUnhandledFrame, translateCodexNotification } from './codex-structured-provider-events' -import { - codexDispatchRejection, - noteCodexTurnOpened, - settleCodexSendsInEndedTurn -} from './codex-structured-turn-end-settlement' +import * as turnEnd from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' +import { codexStoppedRequestEndWait, settleCodexRequestEndWaiters } from './codex-request-end-wait' import { changeCodexThreadGoal } from './codex-structured-thread-goal' import { startCodexTerminalStopProbe } from './codex-background-terminals' import { codexBackgroundTaskStops, stopCodexBackgroundTasks } from './codex-background-task-stops' @@ -141,7 +138,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap event: CodexStructuredSessionEvent ): CodexJournalTranslationAdmission { if (event.type === 'notification') { - noteCodexTurnOpened(session, event.method, event.params) + turnEnd.noteCodexTurnOpened(session, event.method, event.params) if (event.method === 'skills/changed') { session.skillReport?.refresh(true) } @@ -152,7 +149,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } if (event.type === 'notification') { // Only an admitted turn end settles; a refused one settles on the retry that lands. - settleCodexSendsInEndedTurn(session, event, (settlement) => + turnEnd.settleCodexSendsInEndedTurn(session, event, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) ) // Child records observe refused frames only after a retry reaches the journal. @@ -161,6 +158,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session.backgroundTasks.publishChildWork() startCodexTerminalStopProbe(this.sessions, event.sessionId, session, this.deps) } + settleCodexRequestEndWaiters(session) this.deps.onEvent?.(event) return admission } @@ -246,6 +244,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return await dispatchCodexTurn(session, { ...input, turnOptions }, this.deps.requestTimeoutMs) } finally { session.dispatchPending = false + settleCodexRequestEndWaiters(session) } } @@ -256,6 +255,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap requestTimeoutMs: this.deps.requestTimeoutMs }) + awaitStoppedRequestEnd = codexStoppedRequestEndWait(this.sessions) + rewindSupport: NonNullable = (sessionId) => this.sessions.get(sessionId)?.historyMode === 'legacy' ? { supported: false, reason: 'history-not-paginated' } @@ -283,7 +284,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap if (isCodexAppServerRequestError(error)) { return { state: 'rejected', - ...codexDispatchRejection( + ...turnEnd.codexDispatchRejection( agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) ) } @@ -326,7 +327,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap closeSession: NonNullable = (sessionId) => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + disposeSession: NonNullable = (sessionId) => + this.teardown.close(sessionId) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index 5e663cebb552..2a2181de20ad 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -8,8 +8,10 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexJournalItems } from './codex-structured-journal-items' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -19,12 +21,15 @@ const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a' const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef' const CHILD_ID = '01a07d54-5523-78a3-91f5-e0acb1dab065' -function fakeCodex(close: () => Promise = async () => true): { +function fakeCodex( + close: () => Promise = async () => true, + proof?: StructuredAgentSessionEndedEvent['processExit'] +): { handlers: () => CodexAppServerConnectionHandlers openConnection: typeof openCodexAppServerConnection } { let live: CodexAppServerConnectionHandlers = {} - const openConnection = (async (_launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { live = handlers const connection: CodexAppServerConnection = { pid: 4321, @@ -35,9 +40,14 @@ function fakeCodex(close: () => Promise = async () => true): { respond: () => {}, respondWithError: () => {}, close - } as unknown as CodexAppServerConnection + } + if (proof) { + Object.defineProperty(connection, 'closeResult', { + value: { root: proof.root, tree: proof.workload } + }) + } return connection - }) as typeof openCodexAppServerConnection + } return { handlers: () => live, openConnection } } @@ -77,9 +87,10 @@ async function adapterWithSession( published: Published[], events?: StructuredAgentSessionEventSink, onEvent?: (event: CodexStructuredSessionEvent) => void, - close?: () => Promise + close?: () => Promise, + proof?: StructuredAgentSessionEndedEvent['processExit'] ): Promise<{ adapter: CodexStructuredSessionAdapter; codex: ReturnType }> { - const codex = fakeCodex(close) + const codex = fakeCodex(close, proof) const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', @@ -288,4 +299,45 @@ describe("codex background tasks reach the host's child records", () => { expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) + + it.each([ + [{ root: 'exited', workload: 'exited' }, true], + [{ root: 'exited', workload: 'unverifiable' }, false], + [{ root: 'live', workload: 'exited' }, false] + ] as const)('hands close proof %j to the host child-work path', async (proof, ended) => { + const published: Published[] = [] + const { adapter, codex } = await adapterWithSession( + published, + undefined, + undefined, + undefined, + proof + ) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + published.length = 0 + const drain = vi.spyOn(CodexBackgroundTaskTracker.prototype, 'drainChildWorkEvidence') + try { + expect(await adapter.closeSession('session-1')).toBe(true) + const endings = drain.mock.results.flatMap((result) => + result.type === 'return' ? result.value.filter((edge) => edge.type === 'ended') : [] + ) + expect(endings).toEqual( + ended + ? [ + expect.objectContaining({ + type: 'ended', + handle: { idKind: 'thread_id', id: CHILD_ID, runId: 'child-turn' }, + outcome: 'cancelled' + }) + ] + : [] + ) + expect(published).toEqual([ + { sessionId: 'session-1', evidence: ended ? ['ended', 'session-ended'] : ['session-ended'] } + ]) + } finally { + drain.mockRestore() + } + }) }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts deleted file mode 100644 index ded3278d3e3a..000000000000 --- a/src/main/codex/codex-structured-session-close.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture' -import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection -} from './codex-app-server-connection' -import { - CodexStructuredSessionAdapter, - type CodexStructuredSessionEvent -} from './codex-structured-session-adapter' -import { handleCodexSessionExit } from './codex-structured-session-close' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' -import { CodexPromptRegistry } from './codex-structured-prompt-replies' -import type { CodexSession } from './codex-structured-session-state' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' - -const THREAD = 'thread-1' - -function identity(sessionId: string): AgentSessionJournalIdentity { - return { - sessionId, - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - } -} - -function adapterFixture() { - const connections: { - connection: CodexAppServerConnection - handlers: CodexAppServerConnectionHandlers - }[] = [] - const events: CodexStructuredSessionEvent[] = [] - let generation = 0 - const openConnection = (async (_launch, handlers = {}) => { - const connection: CodexAppServerConnection = { - pid: 4321, - closed: false, - request: async (method) => (method === 'thread/start' ? { thread: { id: THREAD } } : {}), - notify: () => {}, - respond: () => {}, - respondWithError: () => {}, - close: async () => true - } - connections.push({ connection, handlers }) - return connection - }) as typeof openCodexAppServerConnection - const adapter = new CodexStructuredSessionAdapter({ - resolveLaunch: async () => ({ - command: 'codex', - args: ['app-server'], - cwd: '/workspace', - codexHome: null, - resumeThreadId: null - }), - openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - mintAcquisitionGeneration: () => `generation-${++generation}`, - onEvent: (event) => events.push(event) - }) - return { adapter, connections, events } -} - -function claudeAdapterStub(): StructuredAgentSessionAdapter { - return { - acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), - open: vi.fn(async () => ({ reportedOptions: { model: '' } })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -describe('Codex structured session close lifecycle', () => { - function backpressuredSession(requestedClose: boolean) { - const connection: CodexAppServerConnection = { - pid: 4321, - closed: true, - request: async () => ({}), - notify: () => {}, - respond: () => {}, - respondWithError: () => {}, - close: async () => true - } - const prompts = new CodexPromptRegistry() - const translator = { - handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), - dispose: vi.fn() - } as unknown as NonNullable - const session: CodexSession = { - connection, - backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), - ended: false, - ...(requestedClose ? { orcaClose: { requested: true, reason: new Error('closed') } } : {}), - fence: 7, - acquisitionGeneration: 'generation-1', - threadId: THREAD, - prompts, - options: new Map(), - reportedOptions: {}, - dispatchEchoes: createCodexDispatchEchoes(), - turnOpenWaits: createCodexTurnOpenWaits(), - translator - } - return { connection, prompts, translator, session, sessions: new Map([['session-1', session]]) } - } - - it('forwards a one-shot exit when lifecycle admission is rejected', () => { - const { connection, prompts, translator, session, sessions } = backpressuredSession(false) - const clearPrompts = vi.spyOn(prompts, 'clear') - const onEvent = vi.fn() - - expect( - handleCodexSessionExit({ - sessions, - sessionId: 'session-1', - connection, - error: new Error('provider exited'), - prompts, - onEvent - }) - ).toBe(true) - expect(session.ended).toBe(true) - expect(clearPrompts).toHaveBeenCalledOnce() - expect(onEvent).toHaveBeenCalledOnce() - expect(translator.dispose).toHaveBeenCalledOnce() - expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ cause: 'unexpected-exit' }) - expect(translator.handle).toHaveBeenCalledOnce() - }) - - it("ends the session and a Stop's wait for its turn to open when a requested close cannot publish its end", async () => { - const { connection, prompts, session, sessions } = backpressuredSession(true) - let released = false - void session.turnOpenWaits.wait('turn-1', 60_000).then(() => { - released = true - }) - - expect( - handleCodexSessionExit({ - sessions, - sessionId: 'session-1', - connection, - error: new Error('codex session closed'), - closedByOrca: true, - prompts - }) - ).toBe(true) - await Promise.resolve() - expect(session.ended).toBe(true) - expect(released).toBe(true) - }) - - it('mints a distinct child generation even when acquisitions share one fence', async () => { - const { adapter } = adapterFixture() - const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } - - const first = await acquireReadyCodexForTest(adapter, input) - const second = await acquireReadyCodexForTest(adapter, input) - - expect(first.acquisitionGeneration).toBe('generation-1') - expect(second.acquisitionGeneration).toBe('generation-2') - }) - - it('distinguishes an observed provider death from a requested close', async () => { - const { adapter, connections, events } = adapterFixture() - await acquireReadyCodexForTest(adapter, { - identity: identity('session-1'), - fence: 7, - spawnToken: 'spawn-1' - }) - connections[0]?.handlers.onExit?.(new Error('provider exited')) - await acquireReadyCodexForTest(adapter, { - identity: identity('session-2'), - fence: 9, - spawnToken: 'spawn-2' - }) - - await adapter.closeSession('session-2') - - expect(events.filter((event) => event.type === 'ended')).toMatchObject([ - { - cause: 'unexpected-exit', - fence: 7, - acquisitionGeneration: 'generation-1' - }, - { - cause: 'requested-close', - fence: 9, - acquisitionGeneration: 'generation-2' - } - ]) - }) - - it('force-close preserves unexpected-exit evidence when the adapter reports exit during close', async () => { - const { adapter, connections, events } = adapterFixture() - await acquireReadyCodexForTest(adapter, { - identity: identity('session-1'), - fence: 7, - spawnToken: 'spawn-1' - }) - const current = connections[0] - if (!current) { - throw new Error('missing connection') - } - current.connection.close = async () => { - current.handlers.onExit?.(new Error('sink failed')) - return true - } - - await expect(adapter.forceCloseSession?.('session-1')).resolves.toBe(true) - expect(events.filter((event) => event.type === 'ended')).toMatchObject([ - { cause: 'unexpected-exit', reason: 'sink failed', fence: 7 } - ]) - }) - - it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => { - const { adapter, connections, events } = adapterFixture() - const router = claudeAndCodexRouter( - { claude: claudeAdapterStub(), codex: adapter }, - async () => {} - ) - await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) - const current = connections[0] - if (!current) { - throw new Error('missing connection') - } - current.connection.close = async () => { - current.handlers.onExit?.(new Error('journal sink failed')) - return true - } - - const forceCloseSession = router.forceCloseSession - await expect(forceCloseSession('session-1')).resolves.toBe(true) - expect(events.filter((event) => event.type === 'ended')).toMatchObject([ - { cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 } - ]) - }) -}) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 07b6a5bcf557..03323d5d7d4f 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,4 @@ +import { settleCodexRequestEndWaiters } from './codex-request-end-wait' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { closeProcessRegistry } from '@orca/process-host/close-process-registry' @@ -40,6 +41,14 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.orcaClose?.requested ? 'requested-close' : 'unexpected-exit', + ...(input.closedByOrca && input.connection?.closeResult + ? { + processExit: { + root: input.connection.closeResult.root, + workload: input.connection.closeResult.tree ?? 'unverifiable' + } + } + : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -54,9 +63,10 @@ export function handleCodexSessionExit(input: { }) } session.ended = true + settleCodexRequestEndWaiters(session) // Journal recovery settles sends that can no longer receive a provider echo. session.dispatchEchoes.clear() - session.backgroundTasks.clear() + session.backgroundTasks.clear(event.processExit) session.backgroundTasks.publishChildWork() session.unbindReadingControl?.() input.onEvent?.(event) diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 615029931792..1a8ead5b0765 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -33,7 +33,8 @@ export class CodexStructuredSessionTeardown { this.deps.sessions, this.deps.acquisitions, this.deps.onEvent, - this.deps.logger + this.deps.logger, + true ) ) if (closed && connection?.processTreeUnproven) { diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 2c82c11ba823..d0729a194852 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -4,26 +4,34 @@ import { providerDiagnosticOf } from '../../shared/agent-session-failure' import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexSession } from './codex-structured-session-state' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' +import { CODEX_STOP_GRACE_MS } from './codex-request-end-wait' /** -32600 covers inactive turns and invalid/uninitialized requests; -32603 means interrupt submission failed. */ function isCodexTurnNotRunningRefusal(error: unknown): boolean { return isCodexAppServerRequestError(error) && error.code === -32600 } -/** Interrupt acknowledgement confirms Stop; Codex kills one-shot commands and retains background terminals. */ +/** Interrupt acknowledgement confirms the turn stopped; background terminals end with the session. */ export async function interruptCodexTurn(input: { session: CodexSession threadId: string turnId: string requestTimeoutMs?: number + stoppedAt?: number onConfirmed?: () => CodexJournalTranslationAdmission }): Promise { const { session, threadId, turnId } = input + const remaining = + input.stoppedAt === undefined ? Infinity : input.stoppedAt + CODEX_STOP_GRACE_MS - Date.now() + if (remaining <= 0) { + return { cancelled: false, refusal: { turnMayOpen: true } } + } + const timeoutMs = Math.min(input.requestTimeoutMs ?? Infinity, remaining) try { await session.connection.request( 'turn/interrupt', { threadId, turnId }, - { timeoutMs: input.requestTimeoutMs } + { timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined } ) } catch (error) { if (!isCodexAppServerRequestError(error) && !isCodexAppServerUnsupportedError(error)) { diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 84836c11e12a..9fb9a979e2a2 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -54,6 +54,55 @@ describe('a Codex send answered before its turn opens', () => { }) describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { + it.each(['expiry', 'retry', 'replacement'])( + 'spends only the remaining Stop allowance after a late refusal: %s', + async (mode) => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + const request = vi.spyOn(rig.adapter['sessions'].get('session-1')!.connection, 'request') + const interrupt = rig.codex.routes['turn/interrupt']! + let first = true + rig.codex.routes['turn/interrupt'] = async (params) => { + if (!first) { + return interrupt(params) + } + first = false + await new Promise((resolve) => setTimeout(resolve, 4_900)) + try { + return interrupt(params) + } finally { + if (mode === 'replacement') { + rig.turns.start() + rig.adapter['sessions'].delete('session-1') + } + } + } + vi.useFakeTimers() + const stopping = stop(rig) + if (mode === 'retry') { + setTimeout(() => rig.turns.start(), 4_950) + } + await vi.advanceTimersByTimeAsync(5_000) + expect(await stopping).toEqual( + mode === 'retry' + ? { cancelled: true, turnId: 'turn-1' } + : mode === 'expiry' + ? MAY_OPEN + : REFUSED + ) + if (mode === 'expiry') { + rig.turns.start() + } + await vi.advanceTimersByTimeAsync(0) + expect(interruptedTurns(rig)).toEqual(mode === 'retry' ? ['turn-1', 'turn-1'] : ['turn-1']) + expect( + request.mock.calls + .filter(([method]) => method === 'turn/interrupt') + .map((call) => call[2]?.timeoutMs) + ).toEqual(mode === 'retry' ? [5_000, 50] : [5_000]) + } + ) + it("is taken at once once Codex's thread runs the turn", async () => { const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index d102e10c448d..a772ab7af645 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -88,20 +88,27 @@ export async function interruptOpeningCodexTurn( session: CodexOpeningTurnSession, turnId: string, interrupt: () => Promise, - stillCurrent: () => boolean + stillCurrent: () => boolean, + deadline?: number ): Promise { const open = (): ReadonlySet => session.activeTurnIds ?? new Set() const first = await interrupt() if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { return first } + if (!stillCurrent()) { + return { cancelled: false } + } const opened = (): boolean => open().has(turnId) const stillOpening = (): boolean => { const target = codexStopTarget(session) return target !== null && 'opening' in target && target.opening === turnId } if (!opened() && stillOpening()) { - await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) + const remaining = Math.min(CODEX_TURN_OPEN_WAIT_MS, (deadline ?? Infinity) - Date.now()) + if (remaining > 0) { + await session.turnOpenWaits.wait(turnId, remaining) + } if (!stillCurrent()) { return { cancelled: false } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index 29efaf9e5c48..1f9836a5a9d6 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -35,9 +35,9 @@ export class JournalStopMarks { return personStopDecidesTurn(this.deps.state(), turnId, endedAt) } - /** Until settled, unnamed Stops own every turn ending during settlement. */ - beginSettle(): JournalStopSettle | null { - const settle = beginJournalStopSettle(this.deps.state()) + /** Until settled, session-ending Stops own every turn ending during settlement. */ + beginSettle(endsSession = false): JournalStopSettle | null { + const settle = beginJournalStopSettle(this.deps.state(), endsSession) if (settle) { this.edge() } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 9e1475ab3228..61a3791c8da0 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -1,4 +1,4 @@ -// All turn-end writers apply this rule on the journal lane. Unnamed Stop bindings are memory-only +// All turn-end writers apply this rule on the journal lane. Session-ending Stop bindings are memory-only // and disappear on relaunch; provider verdicts and ends observed before Stop retain their meaning. import { @@ -74,15 +74,12 @@ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { return true } -/** A personal Stop owns its named turn, or turns bound during unnamed settlement. */ +/** A session-ending Stop owns every turn ending while it settles; a targeted Stop owns its turn. */ function stopIsTurnCancellation(stop: JournalLatestStop, turnId: string): boolean { if (!stopIsAPersons(stop.event.reason)) { return false } - if (stop.event.turnId !== undefined) { - return stop.event.turnId === turnId - } - return stop.settle?.settling === true || stop.settle?.turnId === turnId + return stop.settle?.settling || stop.settle?.turnId === turnId || stop.event.turnId === turnId } /** Ends observed before Stop keep their meaning even if written later. */ @@ -110,17 +107,19 @@ export function personStopDecidesTurn( return false } if (turnId === null) { - return stop.event.turnId === undefined && stop.settle?.settling === true + return stop.settle?.settling === true } return stopEndsTurnAsCancellation(state, turnId, endedAt) } -/** An unnamed personal Stop owns ending turns until settlement closes. */ +/** Until its child closes, a session-ending Stop owns all turns; other callers stay unnamed-only. */ export function beginJournalStopSettle( - state: Pick + state: Pick, + endsSession = false ): JournalStopSettle | null { const stop = state.queuePauseMarks.latestStop - if (stop === null || stop.event.turnId !== undefined || !stopIsAPersons(stop.event.reason)) { + const targeted = !endsSession && stop?.event.turnId !== undefined + if (stop === null || targeted || !stopIsAPersons(stop.event.reason)) { return null } // Repeated Stop preserves the earlier bound turn. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index 4acd415efc90..97e2f9c22279 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -44,13 +44,15 @@ export class StructuredAgentSessionAcquireAborts { ) } - abort(sessionId: string, reason: string | Error): boolean { + abort(sessionId: string, reason: string | Error): { reached: boolean; handedOver: boolean } { let reached = false + let handedOver = false for (const { controller, handover, goal } of this.inFlight.get(sessionId) ?? []) { reached ||= (handover || goal) && !controller.signal.aborted + handedOver ||= handover && !controller.signal.aborted controller.abort(typeof reason === 'string' ? new Error(reason) : reason) } - return reached + return { reached, handedOver } } /** Abort all waits, including queued attach-drain work. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts deleted file mode 100644 index 1f09f351bc14..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - AgentJournalQuestionItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type { - AgentSessionAcquisition, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { - return { - sessionId, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: claudeProviderHandle('provider-session-1', null) - } -} - -function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { - return { - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created', - mintedAtFence: fence, - observedAt: 1 - }, - acquisitionGeneration: `generation-${fence}` - } -} - -function adapterOf( - releaseAcquisition: StructuredAgentSessionAdapter['releaseAcquisition'] -): StructuredAgentSessionAdapter { - return { - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), - open: vi.fn(async () => ({ reportedOptions: { model: '' } })), - releaseAcquisition, - hasOwnedAcquisition: () => true, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { - it('retains the actual owner after an unproved release for a scoped retry', async () => { - const failure = new Error('root exited') - const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) - - await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) - await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) - expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2) - expect(codex.releaseAcquisition).not.toHaveBeenCalled() - }) -}) - -describe('StructuredAgentSessionAdapterRouter.closeSession', () => { - it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => { - const claude = adapterOf(vi.fn(async () => true)) - const closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) - const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) - claude.closeSession = closeSession - claude.dispatch = dispatch - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) - - await expect(router.closeSession('session-1')).resolves.toBe(false) - await expect( - router.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: { kind: 'message', role: 'user', blocks: [] }, - fence: 1 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - await expect(router.closeSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) - }) - - it('retains a stop proof across journal-close failure until the host acknowledges release', async () => { - const closeSession = vi.fn(async () => true) - const closeJournal = vi.fn(async () => { - throw new Error('journal close failed') - }) - const claude = adapterOf(vi.fn(async () => true)) - claude.closeSession = closeSession - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - async () => {} - ) - const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) - - await expect(router.closeSession('session-1')).resolves.toBe(true) - await expect(closeJournal()).rejects.toThrow('journal close failed') - await expect(router.closeSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledOnce() - router.acknowledgeSessionRelease('session-1') - await expect(router.closeSession('session-1')).resolves.toBe(false) - - await router.acquire({ identity, fence: 2, spawnToken: 'spawn-2' }) - await expect(router.closeSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledTimes(2) - }) -}) - -describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => { - it.each([ - ['forceCloseSession', 'forceCloseSession'], - ['disposeSession', 'disposeSession'] - ] as const)( - '%s forwards to the owner and retains it until proven stopped', - async (_label, method) => { - const claude = adapterOf(vi.fn(async () => true)) - const stop = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) - claude[method] = stop - const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) - claude.dispatch = dispatch - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) - const stopSession = router[method] - - await expect(stopSession('session-1')).resolves.toBe(false) - await expect( - router.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: { kind: 'message', role: 'user', blocks: [] }, - fence: 1 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - await expect(stopSession('session-1')).resolves.toBe(true) - expect(stop).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledOnce() - } - ) - - it.each(['forceCloseSession', 'disposeSession'] as const)( - 'falls back to closeSession when an owner lacks %s', - async (method) => { - const closeSession = vi.fn().mockResolvedValue(true) - const claude = adapterOf(vi.fn(async () => true)) - claude.closeSession = closeSession - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - await router.acquire({ - identity: claudeIdentity('session-1'), - fence: 1, - spawnToken: 'spawn-1' - }) - const stopSession = router[method] - - await expect(stopSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledWith('session-1') - } - ) -}) - -const QUESTION: AgentJournalQuestionItem = { - kind: 'question', - question: 'Which branch?', - options: [{ id: 'main', label: 'main' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } -} - -describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { - it("answers for the session's live owner, and keeps the child with none", async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.stopEndsSession = () => true - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - - expect(router.stopEndsSession('session-1')).toBe(false) - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - expect(router.stopEndsSession('session-1')).toBe(true) - }) - - it("waits on the session's live owner to wind the Stop down, and on nothing with none", async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.awaitStoppedRequestEnd = vi.fn(async () => undefined) - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - - await router.awaitStoppedRequestEnd('session-1', 5) - expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled() - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - await router.awaitStoppedRequestEnd('session-1', 5) - expect(claude.awaitStoppedRequestEnd).toHaveBeenCalledWith('session-1', 5) - }) - - it("answers a card's Cancel as the session's live owner does, and leaves it to cancelTurn with none", async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.routePromptCancel = () => ({ kind: 'stop' }) - const codex = adapterOf(vi.fn(async () => false)) - const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - - expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined() - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toEqual({ - kind: 'stop' - }) - }) -}) - -describe('StructuredAgentSessionAdapterRouter.closeAll', () => { - it('refuses to acquire once the global close proof is published', async () => { - const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) - const claude = adapterOf(vi.fn(async () => true)) - claude.acquire = acquire - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - async () => undefined - ) - await router.closeAll() - - await expect( - router.acquire({ - identity: claudeIdentity('session-1'), - fence: 1, - spawnToken: 'spawn-1' - }) - ).rejects.toThrow('router is closed') - expect(acquire).not.toHaveBeenCalled() - }) - - it('keeps a per-session stop proof and reports no stop for a session it never routed', async () => { - const claude = adapterOf(vi.fn(async () => true)) - const closeAdapters = vi.fn(async () => undefined) - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - closeAdapters - ) - await router.acquire({ - identity: claudeIdentity('session-1'), - fence: 1, - spawnToken: 'spawn-1' - }) - - await router.closeAll() - - // Unrouted sessions have no stop receipt even after adapter-wide shutdown. - await expect(router.closeSession('session-1')).resolves.toBe(true) - await expect(router.closeSession('never-routed')).resolves.toBe(false) - router.acknowledgeSessionRelease('session-1') - await expect(router.closeSession('session-1')).resolves.toBe(false) - await router.closeAll() - expect(closeAdapters).toHaveBeenCalledOnce() - }) - - it('asks the adapters to release an unrouted session rather than answering from the close proof', async () => { - const claudeRelease = vi.fn(async () => true) - const codexRelease = vi.fn(async () => false) - const router = claudeAndCodexRouter( - { claude: adapterOf(claudeRelease), codex: adapterOf(codexRelease) }, - async () => undefined - ) - await router.closeAll() - - await expect(router.releaseAcquisition({ sessionId: 'never-routed' })).resolves.toBe(true) - expect(claudeRelease).toHaveBeenCalledWith({ sessionId: 'never-routed' }) - expect(codexRelease).toHaveBeenCalledWith({ sessionId: 'never-routed' }) - }) - - it('retains live routes and publishes no global proof when closeAll fails', async () => { - const failure = new Error('adapter shutdown failed') - const claude = adapterOf(vi.fn(async () => true)) - const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) - const closeSession = vi.fn(async () => true) - claude.dispatch = dispatch - claude.closeSession = closeSession - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - vi.fn(async () => { - throw failure - }) - ) - await router.acquire({ - identity: claudeIdentity('session-1'), - fence: 1, - spawnToken: 'spawn-1' - }) - - await expect(router.closeAll()).rejects.toBe(failure) - - await expect(router.closeSession('never-routed')).resolves.toBe(false) - await expect( - router.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: { kind: 'message', role: 'user', blocks: [] }, - fence: 1 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - await expect(router.closeSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledOnce() - }) - - it('keeps the global proof when an acquisition lands mid-close', async () => { - let resolveAcquire!: (value: AgentSessionAcquisition) => void - const closeSession = vi.fn(async () => true) - const claude = adapterOf(vi.fn(async () => true)) - claude.closeSession = closeSession - claude.acquire = vi.fn( - () => - new Promise((resolve) => { - resolveAcquire = resolve - }) - ) - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - async () => undefined - ) - const acquiring = router.acquire({ - identity: claudeIdentity('session-1'), - fence: 2, - spawnToken: 'spawn-2' - }) - - await router.closeAll() - resolveAcquire(acquisition(2, 'spawn-2')) - - await expect(acquiring).rejects.toThrow('router is closed') - await expect(router.closeSession('session-1')).resolves.toBe(true) - expect(closeSession).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 255060ae9501..60b2631d8a3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -118,9 +118,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ?? false - stopEndsSession = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false - awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number) => this.liveOwnerOrNull(sessionId)?.awaitStoppedRequestEnd?.(sessionId, stoppedAt) @@ -187,19 +184,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } closeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession) + this.stopSession(sessionId, (adapter) => adapter.closeSession?.(sessionId)) forceCloseSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) + this.stopSession(sessionId, (adapter) => + (adapter.forceCloseSession ?? adapter.closeSession)?.call(adapter, sessionId) + ) disposeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + this.stopSession(sessionId, (adapter) => + (adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId) + ) private async stopSession( sessionId: string, - selectStop: ( - adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined + stop: (adapter: StructuredAgentSessionAdapter) => Promise | undefined ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -213,8 +212,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return this.releaseAcquisition({ sessionId }) } const { adapter } = route.registration - const stop = selectStop(adapter) - const stopped = await stop?.call(adapter, sessionId) + const stopped = await stop(adapter) if (stopped === true) { route.state = 'stopped' return true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 33790c2c00b6..b934c749a7a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -1,4 +1,4 @@ -// Default Stop keeps successfully interrupted children; default prompt Cancel interrupts their turn. +// Chat Stop ends the captured child for every provider; default prompt Cancel interrupts its turn. import type { AgentJournalApprovalItem, @@ -16,8 +16,6 @@ export type AgentSessionCancelOutcome = { export type AgentSessionPromptCancelRoute = { kind: 'dismiss' } | { kind: 'stop' } export type StructuredAgentSessionAdapterStop = { - /** Opt into child termination after Stop; stale-target refusals keep it, failed interrupts of live targets end it. */ - stopEndsSession?(sessionId: string): boolean /** Wait for in-flight work or grace expiry measured from interrupt issuance. */ awaitStoppedRequestEnd?(sessionId: string, stoppedAt: number): Promise /** Undefined routes through cancelTurn with the prompt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 992e8f5fa941..46fb03e2ec6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -179,6 +179,11 @@ export type StructuredAgentSessionEndedEvent = { /** Absent means provider exit without extra detail; host faults retain their own classification. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' + /** Execution-host observations from a requested provider close, when available. */ + processExit?: { + root: 'live' | 'unverifiable' | 'exited' + workload: 'live' | 'unverifiable' | 'exited' + } fence: number acquisitionGeneration: string /** Observed child exit supplies interrupted-turn end time. */ @@ -285,6 +290,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & turnId?: string fence: number prompt?: { itemId: string } + /** Original host Stop time; in-process only, for a Stop that ends the session. */ + stoppedAt?: number dispatchStatus?: { state: AgentJournalDispatchState; recovered: boolean } | null /** Re-read journal turn immediately before interrupt; direct callers may omit this guard. */ resolveLiveTurnId?: () => string | null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts index 787dd246cd2e..336c586c47e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts @@ -1,15 +1,13 @@ -// Capture once at acceptance, then recheck the target before acting so Stop cannot reach newer work. +// Capture once at acceptance; afterward only child identity can exclude the accepted target. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { stopReachesUnrecordedWork } from './structured-agent-session-queued-stop' import { isMainAgentWorking } from './structured-agent-session-turns-cancel' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' -import { - structuredAgentSessionStopNamesEndedTurn, - structuredAgentSessionStoppedTurnId -} from './structured-agent-session-turn-stop-notes' +import { structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' /** close joins shutdown; starting ends startup; interrupt reaches live work; withdraw only settles queued sends. */ export type ChatStopReach = 'close' | 'starting' | 'interrupt' | 'withdraw' @@ -18,6 +16,7 @@ export type ChatStopTarget = { reach: ChatStopReach child: StructuredAgentSessionProviderChild | null turnId: string | null + turnScope: AgentJournalTurnScope named: boolean /** Named turn unless ending the provider session; then use the turn live at event write. */ eventTurnId: string | null @@ -35,6 +34,7 @@ export function captureChatStopTarget( turnId?: string endsSession: boolean stoppedPrePrompt?: boolean + stoppedSend?: boolean } ): ChatStopTarget | null { const { child, turnId } = input @@ -50,6 +50,7 @@ export function captureChatStopTarget( turnId ), named: turnId !== undefined, + turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope(), eventTurnId: input.endsSession ? null : (turnId ?? null), queued } @@ -59,37 +60,10 @@ export function captureChatStopTarget( if (child?.phase === 'starting') { return { ...target, reach: 'starting', marks: true } } - // Unnamed Stops use the same current-work rule as session lists. - if (!child || (turnId === undefined && !isMainAgentWorking(ctx))) { + // An aborted send remains this Stop's work even after its rejected answer reaches the fold. + if (!child || (turnId === undefined && !input.stoppedSend && !isMainAgentWorking(ctx))) { return queued || input.stoppedPrePrompt ? { ...target, reach: 'withdraw', marks: true } : null } // Repeated Stop must not pause cards queued after the first press. return { ...target, reach: 'interrupt', marks: queued || reached === 'unrecorded' } } - -/** Revalidate child identity and named turn after durable acceptance. */ -export function chatStopTargetStands( - ctx: AgentSessionTurnContext, - target: ChatStopTarget, - currentChild: StructuredAgentSessionProviderChild | null | undefined -): boolean { - if (currentChild !== target.child) { - return false - } - if (target.reach !== 'interrupt') { - return true - } - if (target.named) { - // Only named turns without records may still be opening. - return ( - target.turnId !== null && - !structuredAgentSessionStopNamesEndedTurn( - ctx.journal, - contextStructuredAgentSessionCurrentWork(ctx), - target.turnId - ) - ) - } - // The held lane prevents newer sends; unnamed Stop may reach a preexisting send’s newly opened turn. - return isMainAgentWorking(ctx) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index cacdd9159a5c..76b579fec6f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -14,7 +14,6 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import type { MutationPlan } from './structured-agent-session-mutation-plans' import { captureChatStopTarget, - chatStopTargetStands, type ChatStopTarget } from './structured-agent-session-chat-stop-target' import { @@ -31,9 +30,10 @@ import { endStoppedStructuredAgentSession, type StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' -import { performCancel } from './structured-agent-session-turns-cancel' +import { performCancel, type AcceptedChatStop } from './structured-agent-session-turns-cancel' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { structuredAgentSessionCommandWasStopped } from './structured-agent-session-command-turn' type ChatStopOutcome = TurnOutcome @@ -42,7 +42,12 @@ export type StructuredAgentSessionChatStopRun = { outcome: ChatStopOutcome; ends export function mutateWithChatStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope; turnId?: string; stoppedPrePrompt?: boolean }, + params: { + envelope: AgentSessionMutationEnvelope + turnId?: string + stoppedPrePrompt?: boolean + stoppedSend?: boolean + }, plan: MutationPlan, run: ( ctx: AgentSessionTurnContext, @@ -53,11 +58,14 @@ export function mutateWithChatStop( const { sessionId } = envelope // Replayed Stops owe no second wind-down. let windDown: - | { owed: StructuredAgentSessionStopWindDown; ctx: AgentSessionTurnContext } + | { + owed: StructuredAgentSessionStopWindDown + ctx: AgentSessionTurnContext + stopChild: () => Promise + } | undefined let reached = params.stoppedPrePrompt === true const named = turnId !== undefined ? { turnId } : {} - const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) const logChildEnd = (error: unknown) => context.deps.logger.warn('ending the agent process on Stop failed', { scope: 'stop-child', @@ -73,6 +81,17 @@ export function mutateWithChatStop( const accepted = await acceptStopTarget(ctx) return accepted.ok ? { ok: true, value: { settled: false, turnId: null } } : accepted } + const sinceStop = ctx.journal.queuedMessages.userStopInForce()?.sequence + const handed = ctx.journal + .submissions() + .some( + (entry) => + entry.fence === ctx.fence && + (entry.handoverRecorded !== true || entry.handedOverAt !== undefined) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && !entry.recovered)) && + (sinceStop === undefined || (entry.submittedSequence ?? -1) > sinceStop) + ) const accepted = await acceptChatStop(ctx, { event: { reason: 'user-stop', @@ -85,7 +104,10 @@ export function mutateWithChatStop( ? { ok: true, value: { - settled: target.queued || reached, + settled: + (accepted.value.withdrawn?.length ?? (target.queued ? 1 : 0)) > 0 || + handed || + params.stoppedSend === true, turnId: accepted.value.turnId } } @@ -94,14 +116,20 @@ export function mutateWithChatStop( const act = async ( ctx: AgentSessionTurnContext, target: ChatStopTarget, - settled: boolean + settled: boolean, + acceptedStop: AcceptedChatStop ): Promise => { - // Recheck target identity so newer children/turns remain untouched. + // Turns may advance during acceptance; only a replacement child escapes this Stop. const current = context.sessions.get(ctx.sessionId)?.child - if (target.reach === 'withdraw' || !chatStopTargetStands(ctx, target, current)) { + if (target.reach === 'withdraw' || current !== target.child) { return { ok: true, value: { ...named, cancelled: settled } } } const { child } = target + const stopChild = async () => { + if (context.sessions.get(sessionId)?.child === child) { + await context.stopAgent(sessionId, { recorded: 'user-stop' }) + } + } if (target.reach === 'close') { // Join existing close and retry exit proof; the child already refuses input. await stopChild().catch(logChildEnd) @@ -118,12 +146,13 @@ export function mutateWithChatStop( { clientOperationId: envelope.clientOperationId, ...named, + acceptedStop, stopChild, onStopChildError: logChildEnd, // Host drops the child only after proven exit; no other work runs meanwhile. childReleased: () => context.sessions.get(sessionId)?.child !== child, endSession: (owed) => { - windDown = { owed, ctx } + windDown = { owed, ctx, stopChild } }, withdrewQueued: Promise.resolve(settled), noteFence: () => context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? ctx.fence, @@ -138,12 +167,15 @@ export function mutateWithChatStop( const target = captureChatStopTarget(ctx, { child: context.sessions.get(ctx.sessionId)?.child ?? null, ...named, - endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true, - stoppedPrePrompt: reached || operations.length > 0 || flight !== undefined + endsSession: true, + stoppedPrePrompt: reached || operations.length > 0 || flight !== undefined, + stoppedSend: params.stoppedSend }) if (!target) { return { ok: true, value: { ...named, cancelled: false } } } + const previouslyStopped = + target.turnId !== null && structuredAgentSessionCommandWasStopped(ctx.journal, target.turnId) const accepted = await accept(ctx, target) if (!accepted.ok) { return accepted @@ -156,16 +188,30 @@ export function mutateWithChatStop( if (flight && (target.queued || target.reach === 'withdraw' || target.reach === 'starting')) { flight.abort() } - if (reached && target.reach === 'withdraw') { - await writeStructuredAgentSessionStopNote( - ctx, - accepted.value.turnId ?? envelope.clientOperationId, - { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, - contextStructuredAgentSessionCurrentWork(ctx).turnScope() - ) + const noteTurnId = accepted.value.turnId ?? target.turnId + const acceptedStop: AcceptedChatStop = { + turnId: noteTurnId, + turnScope: target.turnScope, + previouslyStopped, + ...((target.reach === 'interrupt' && (target.marks || target.turnId !== null)) || + accepted.value.settled + ? { + requestNoteWrite: writeStructuredAgentSessionStopNote( + ctx, + noteTurnId ?? envelope.clientOperationId, + { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, + target.turnScope + ) + } + : {}) } try { - return await act(ctx, target, accepted.value.settled || reached || flight !== undefined) + return await act( + ctx, + target, + accepted.value.settled || reached || flight !== undefined, + acceptedStop + ) } catch (error) { // Note post-commit failure on the most specific turn, then answer from the durable receipt. const stoppedTurnId = @@ -195,7 +241,7 @@ export function mutateWithChatStop( // Queue in the mutation’s tick so intervening sends wait behind child shutdown. void context.serialize(sessionId, async () => { if (windDown) { - const { owed, ctx } = windDown + const { owed, ctx, stopChild } = windDown await endStoppedStructuredAgentSession( { ...ctx, adapter: context.deps.adapter }, owed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index a6169e12465c..c6fbc02cb1e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -36,7 +36,6 @@ async function endClosedStructuredAgentSessionChild( await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca', - ...(child.close?.failure ? { failure: child.close.failure } : {}), ...(unanswered ? { startupUnanswered: true } : {}) }) context.restartWitness?.stopped(sessionId) @@ -88,12 +87,14 @@ function closeProviderRoot( ): Promise { const { adapter, logger } = context.deps // Missing close means no owned process; implemented closes must prove exit. - const stop = adapter.disposeSession ?? adapter.closeSession - if (!stop) { + if (!adapter.disposeSession && !adapter.closeSession) { return Promise.resolve(true) } return stopAgentSessionProviderRoot( - () => stop.call(adapter, sessionId), + async () => { + const close = adapter.disposeSession ?? adapter.closeSession + return (await close?.call(adapter, sessionId)) === true + }, // Proven exit stands; descendant/follow-up failures are log-only. (error) => logger.warn("the agent's process exited, but its close did not finish cleanly", { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index d59233067ff2..216403daca2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -132,6 +132,18 @@ async function expectHeld(id: string): Promise { expect(claude.connections[0]!.sent).toEqual([]) } +it('ends empty initialization without a cancellation request row', async () => { + expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect( + (await host.journalSnapshot(SESSION)).items.filter((item) => item.body.kind === 'status') + ).toEqual([]) + expect(claude.connections[0]!.closeCount).toBeGreaterThan(0) +}) + it('holds the message from a start that never answers, and a Stop ends it: stopped, nothing working', async () => { expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') const body = hostTestMessage('hello') @@ -150,6 +162,11 @@ it('holds the message from a start that never answers, and a Stop ends it: stopp const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) + ).toEqual(['Cancellation requested.']) await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) expect(connection!.closeCount).toBeGreaterThan(0) await vi.waitFor(async () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index d0be6894ae8c..7d09dc9bbbdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -4,13 +4,20 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' +import { + projectStructuredAgentSessionStatus, + projectStructuredAgentSessionStatusState +} from '../../../shared/structured-agent-session-projection' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { + claudeFrame as frame, + claudeWasSent as wrote, fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' @@ -79,8 +86,8 @@ beforeEach(async () => { claudeConfigDir: join(root, 'claude-home'), providerSessionId: PROVIDER_SESSION_ID, resumeLeafUuid: null, - resumesTranscript: false, - continuesChain: false + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 }), onEvent: (event) => { const mapped = structuredClaudeLifecycleEvent(event) @@ -360,3 +367,71 @@ it('a card sent now into the running turn comes back paused when Claude reports expect(connection.sent).toHaveLength(2) expect(await sends()).toEqual([{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }]) }, 20_000) + +it('ends the captured child when a named Stop sees its next turn open during acceptance', async () => { + const connection = claude.connections[0]! + const close = vi.spyOn(connection, 'close') + const stoppedTurn = await openFirstTurn(connection) + const laneDrained = () => host['tasks'].serialize(SESSION, async () => {}) + const statusTexts = async () => + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [String(item.body.text)] : [] + ) + await send('Already handed-over follow-up.') + await eventually(() => expect(wrote(connection, 'Already handed-over follow-up.')).toBe(true)) + const followUp = connection.sent.at(-1)! + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const accept = journal.stops.accept.bind(journal.stops) + vi.spyOn(journal.stops, 'accept').mockImplementationOnce(async (...args) => { + const accepted = await accept(...args) + frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'result-1' }) + frame(connection, followUp) + frame(connection, { + type: 'assistant', + uuid: 'follow-up-leaf', + parent_tool_use_id: null, + message: { id: 'msg-2', role: 'assistant', content: [{ type: 'text', text: 'Next reply' }] } + }) + await eventually(async () => { + const live = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + expect(live).not.toBeNull() + expect(live).not.toBe(stoppedTurn) + }) + return accepted + }) + + await expect(stop(stoppedTurn)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(close).toHaveBeenCalledOnce() + expect(connection.closed).toBe(true) + expect(await statusTexts()).toEqual(['Cancellation requested.']) + const snapshot = await host.journalSnapshot(SESSION) + const turn = readAgentJournalTurn( + snapshot.items.findLast((item) => item.body.kind === 'turn')?.body + ) + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + const { summary } = projectStructuredAgentSessionStatusState( + snapshot.items, + snapshot.submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) + expect(structuredAgentSessionAgentStatus({ ...summary, status: summary.status! })).toMatchObject({ + mainAgent: { outcome: 'cancellation' } + }) + + await send('New child.') + await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, 'New child.')).toBe(true) + }) + const resumed = claude.connections.at(-1)! + await expect(stop(stoppedTurn)).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + await laneDrained() + expect(close).toHaveBeenCalledOnce() + expect(resumed.closed).toBe(false) + expect(resumed.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(await statusTexts()).toEqual(['Cancellation requested.']) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts deleted file mode 100644 index c94c1021c487..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -// Codex interrupt replies precede turn-end frames; preserve note projection across both deliveries. - -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -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 { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' -import type { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} 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' } - -let events: AgentSessionSubscribeEvent[] -let unconfirmedNote: AgentJournalRenderItem | undefined -let root: string -let host: StructuredAgentSessionHost -let turns: ReturnType -let codex: ReturnType -let notify: (method: string, params: unknown) => void -let launch: { resumeThreadId?: string | null } -let adapter: CodexStructuredSessionAdapter - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) - resetHostTestOperationIds() - events = [] - unconfirmedNote = undefined - codex = fakeCodex() - notify = (method, params) => codex.connections.at(-1)?.handlers.onNotification?.(method, params) - turns = codexTurnLifecycleFake(THREAD, () => notify) - codex.routes['turn/start'] = turns.routes['turn/start'] - codex.routes['turn/interrupt'] = () => { - const turnId = turns.turnId - setTimeout( - () => - notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), - 4 - ) - return {} - } - const store = await openTestAgentSessionRecordStore(root) - launch = {} - adapter = adapterFor(codex, launch, [], { - onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), - onEvent: (event) => { - if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { - void host.handleAdapterEvent(event) - } - } - }) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: recordingStructuredAgentSessionLogger().logger, - store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.subscribe({ - id: 'stop-note-live', - sessionId: SESSION, - emit: (event) => events.push(event) - }) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function send(text: string) { - const body = hostTestMessage(text) - return host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) -} - -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 - }) -} - -async function runningTurn(): Promise { - const sent = await send('count to 40') - if (!sent.ok) { - throw new Error(JSON.stringify(sent.refusal)) - } - await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) - turns.start() - turns.echo(sent.value.clientMessageId) - await vi.waitFor(async () => - expect((await notesAndTurn()).turn?.body).toMatchObject({ state: 'running' }) - ) -} - -async function notesAndTurn() { - await host.flushStreamedEvents(SESSION) - const items = (await host.journalSnapshot(SESSION)).items - return { - notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), - turn: items.find((item) => item.body.kind === 'turn') - } -} - -async function unconfirmedStop() { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - throw new CodexAppServerTimeoutError('codex app-server turn/interrupt exceeded 30000ms') - } - const connection = codex.connections[0]! - const close = connection.close - connection.close = async () => { - connection.close = close - connection.closeCount += 1 - return false - } - await expect(stop()).resolves.toMatchObject({ ok: true }) - const { notes, turn } = await notesAndTurn() - expect(notes).toHaveLength(1) - expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) - expect(turn?.body).toMatchObject({ state: 'running' }) - unconfirmedNote = notes[0] - return connection -} - -async function confirmedNote(): Promise { - await vi.waitFor(async () => { - const { notes, turn } = await notesAndTurn() - expect(turn?.body).toMatchObject({ state: 'interrupted' }) - expect(notes).toHaveLength(1) - expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) - expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) - expect(notes[0]).toMatchObject({ - itemId: unconfirmedNote?.itemId, - revision: unconfirmedNote?.revision, - sequence: unconfirmedNote?.sequence - }) - const journal = host['sessions'].get(SESSION)!.journal - expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ - failure: { kind: 'cancelUnconfirmed' } - }) - expect( - events.some( - (event) => - event.type === 'batch' && - event.batch.items.some( - (item) => - item.itemId === notes[0]?.itemId && - item.body.kind === 'status' && - item.body.text === 'Cancellation requested.' && - !item.body.failure - ) && - event.batch.items.some( - (item) => - item.itemId === turn?.itemId && - item.body.kind === 'turn' && - item.body.state === 'interrupted' - ) - ) - ).toBe(true) - }) -} - -it('projects the note on a late self-exit after the kill timed out', async () => { - const connection = await unconfirmedStop() - connection.handlers.onExit?.(new Error('codex exited'), { expected: true }) - await confirmedNote() -}) - -it('projects the note when a second Stop joins the close and proves the exit', async () => { - await unconfirmedStop() - await expect(stop()).resolves.toMatchObject({ ok: true }) - await confirmedNote() -}) - -it('projects the note when a send joins the close and proves the exit', async () => { - const connection = await unconfirmedStop() - await expect(send('Carry on.')).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => { - expect(codex.connections.at(-1)).not.toBe(connection) - expect(connection.closeCount).toBe(2) - }) - await confirmedNote() -}) - -it("projects the note when Codex's own interrupted result ends the turn", async () => { - await unconfirmedStop() - turns.end('interrupted') - await confirmedNote() -}) - -it('keeps the note unconfirmed when the turn completes', async () => { - await unconfirmedStop() - turns.end('completed') - await vi.waitFor(async () => { - const { notes, turn } = await notesAndTurn() - expect(turn?.body).toMatchObject({ state: 'completed' }) - expect(notes).toHaveLength(1) - expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts deleted file mode 100644 index 9ae60e458a67..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -// Codex interrupt replies precede turn-end frames; preserve Stop rows across both deliveries. - -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { CodexAppServerRequestError } from '../../codex/codex-app-server-connection' -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 { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let host: StructuredAgentSessionHost -let turns: ReturnType -let codex: ReturnType -let notify: (method: string, params: unknown) => void -let launch: { resumeThreadId?: string | null } -let log: ReturnType -let disposeSession: MockInstance> - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) - resetHostTestOperationIds() - codex = fakeCodex() - notify = (method, params) => codex.connections.at(-1)?.handlers.onNotification?.(method, params) - turns = codexTurnLifecycleFake(THREAD, () => notify) - codex.routes['turn/start'] = turns.routes['turn/start'] - codex.routes['turn/interrupt'] = () => { - const turnId = turns.turnId - setTimeout( - () => - notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), - 4 - ) - return {} - } - const store = await openTestAgentSessionRecordStore(root) - launch = {} - const adapter = adapterFor(codex, launch, [], { - onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), - onEvent: (event) => { - if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { - void host.handleAdapterEvent(event) - } - } - }) - disposeSession = vi.spyOn(adapter, 'disposeSession') - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: (log = recordingStructuredAgentSessionLogger()).logger, - store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function send(text: string) { - const body = hostTestMessage(text) - return host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) -} - -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 - }) -} - -async function runningTurn(): Promise { - const sent = await send('count to 40') - if (!sent.ok) { - throw new Error(JSON.stringify(sent.refusal)) - } - await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) - turns.start() - turns.echo(sent.value.clientMessageId) - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['running'])) -} - -async function journalRows() { - const items = (await host.journalSnapshot(SESSION)).items - return { - statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), - turns: items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.state] : [])), - outcomes: items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.outcome] : [])) - } -} - -/** -32600 proves no running target; -32603 leaves the turn potentially running. */ -function refused(message: string, code: -32600 | -32603 = -32600): CodexAppServerRequestError { - return new CodexAppServerRequestError( - 'turn/interrupt', - code, - `codex app-server turn/interrupt failed: ${message}`, - message - ) -} - -function interruptFailure(failure: 'internal error' | 'unanswered'): Error { - return failure === 'internal error' - ? refused('failed to interrupt turn: channel closed', -32603) - : new CodexAppServerTimeoutError('codex app-server turn/interrupt exceeded 30000ms') -} - -async function followUpUnopened(): Promise { - const sent = await send('and then this') - if (!sent.ok) { - throw new Error(JSON.stringify(sent.refusal)) - } - await vi.waitFor(() => expect(turns.turnId).toBe('turn-2')) - await host.flushStreamedEvents(SESSION) -} - -function childEndedByStop(): boolean { - return disposeSession.mock.calls.length > 0 -} - -describe('a Codex Stop that Codex answered', () => { - it.each([ - ['names no turn', undefined], - ['names its turn, as an older client sends it', 'turn-1'] - ] as const)( - 'reads as requested, with the turn interrupted, when it %s', - async (_case, turnId) => { - await runningTurn() - - const stopped = await stop(turnId) - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['interrupted'])) - await host.flushStreamedEvents(SESSION) - - expect((await journalRows()).statuses).toEqual(['Cancellation requested.']) - expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - // Successful Stop preserves Codex’s child-owned background terminals. - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - } - ) -}) - -describe('a Codex Stop whose interrupt failed', () => { - it.each([ - ['Codex could not submit it, naming no turn', undefined, 'internal error'], - ['Codex could not submit it, naming its turn', 'turn-1', 'internal error'], - ['unanswered, naming no turn', undefined, 'unanswered'], - ['unanswered, naming its turn', 'turn-1', 'unanswered'] - ] as const)( - "ends the child still running the turn, as the user's cancellation, when %s", - async (_case, turnId, failure) => { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - throw interruptFailure(failure) - } - - const stopped = await stop(turnId) - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(codex.connections.at(-1)?.closed).toBe(true) - const rows = await journalRows() - expect(rows.turns).toEqual(['interrupted']) - expect(rows.outcomes).toEqual(['cancellation']) - expect(rows.statuses).toEqual(['Cancellation requested.']) - } - ) - - it('says Codex did not stop when a Stop naming the running turn could not prove the exit', async () => { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - throw interruptFailure('internal error') - } - disposeSession.mockResolvedValueOnce(false) - - const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect((await journalRows()).statuses).toEqual([ - "Codex didn't stop: failed to interrupt turn: channel closed." - ]) - }) - - it.each([ - ['naming no turn', undefined], - ['naming its turn', 'turn-1'] - ] as const)( - 'keeps the child when Codex has moved on to another turn, %s', - async (_case, turnId) => { - await runningTurn() - // Race turn replacement ahead of interrupt delivery. - codex.routes['turn/interrupt'] = () => { - turns.end('completed') - notify('turn/started', { threadId: THREAD, turn: { id: 'turn-2', status: 'inProgress' } }) - throw refused('expected active turn id turn-1 but found turn-2') - } - - const stopped = await stop(turnId) - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).turns).toEqual(['completed', 'running']) - } - ) - - // Codex marks turns ended before emitting their frames, so refusal can arrive first. - it.each([ - ['naming no turn', undefined], - ['naming its turn', 'turn-1'] - ] as const)( - "keeps the child when Codex's refusal arrives before the turn's end, %s", - async (_case, turnId) => { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - setTimeout(() => turns.end('completed'), 2) - throw refused('no active turn to interrupt') - } - - const stopped = await stop(turnId) - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).outcomes).toEqual(['success']) - } - ) - - it.each([ - ['naming no turn', undefined], - ['naming its turn', 'turn-1'] - ] as const)( - 'keeps the child when the turn ended before the interrupt reached it, %s', - async (_case, turnId) => { - await runningTurn() - codex.routes['turn/interrupt'] = (params) => { - turns.end('completed') - return turns.routes['turn/interrupt'](params) - } - - const stopped = await stop(turnId) - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).turns).toEqual(['completed']) - } - ) - - it('keeps the child at rest, and writes no row, when a Stop names a turn that already ended', async () => { - await runningTurn() - turns.end('completed') - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) - codex.routes['turn/interrupt'] = turns.routes['turn/interrupt'] - - const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - expect((await journalRows()).statuses).toEqual([]) - }) - - it.each([ - ['Codex refused it', 'refused'], - ['the interrupt went unanswered', 'unanswered'] - ] as const)( - 'keeps the child when a Stop names a turn that ended and a follow-up has not opened, %s', - async (_case, failure) => { - await runningTurn() - turns.end('completed') - await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) - await followUpUnopened() - codex.routes['turn/interrupt'] = - failure === 'refused' - ? turns.routes['turn/interrupt'] - : () => { - throw interruptFailure('unanswered') - } - - const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect(codex.connections.at(-1)?.closed).toBe(false) - } - ) - - it("decides on Codex's frames received before the interrupt failed, not on the journal's last write", async () => { - await runningTurn() - // Land provider frames before interrupt failure to test current journal state. - codex.routes['turn/interrupt'] = () => { - turns.end('completed') - notify('turn/started', { threadId: THREAD, turn: { id: 'turn-2', status: 'inProgress' } }) - throw interruptFailure('internal error') - } - - const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - expect((await journalRows()).turns).toEqual(['completed', 'running']) - }) - - it('holds a card queued before it while the child it ends goes, its event written first', async () => { - await runningTurn() - const body = hostTestMessage('queued before the Stop') - const delivery = 'queue-if-active' as const - const queued = await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body, delivery } - }) - }, - body, - delivery - }) - if (!queued.ok || !('queued' in queued.value)) { - throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) - } - const cardId = queued.value.queued.messageId - codex.routes['turn/interrupt'] = () => { - throw interruptFailure('internal error') - } - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await host.flushStreamedEvents(SESSION) - await new Promise((resolve) => setTimeout(resolve, 250)) - - expect(childEndedByStop()).toBe(true) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.queuePause).toEqual({ reason: 'stopped' }) - expect( - (await host.journalSnapshot(SESSION)).submissions.filter( - (entry) => entry.queuedMessageId === cardId - ) - ).toEqual([]) - const journal = host['sessions'].get(SESSION)!.journal - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - const rows = since.ok ? since.rows : [] - const stopAt = rows.find((row) => row.kind === 'tombstone' && row.stopEvent)?.seq - const endAt = rows.find((row) => JSON.stringify(row).includes('"state":"interrupted"'))?.seq - expect(stopAt).toBeLessThan(endAt ?? 0) - }) - - it('leaves a child that exited during the interrupt to its exit', async () => { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - const exited = new Error('codex app-server exited') - codex.connections.at(-1)?.handlers.onExit?.(exited) - throw exited - } - - const stopped = await stop() - await host.flushStreamedEvents(SESSION) - - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(childEndedByStop()).toBe(false) - }) -}) - -describe('a message after a Codex Stop whose exit was unproven', () => { - /** Refuse requests once close begins; inject failures unproven closes before exit proof. */ - async function stopWithUnprovenClose(failures: number) { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - throw interruptFailure('internal error') - } - const old = codex.connections.at(-1)! - const close = old.close - const request = old.request - let unproven = failures - old.close = async () => { - old.closed = true - if (unproven === 0) { - return close() - } - unproven -= 1 - return false - } - old.request = (method, params) => - old.closed - ? Promise.reject(new Error('codex app-server is closing')) - : request(method, params) - await stop() - await host.flushStreamedEvents(SESSION) - return old - } - - const startedWith = (connection: (typeof codex.connections)[number], text: string) => - connection.calls.some( - (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes(text) - ) - - it('rejects the message while the close stays unverifiable, and starts no Codex beside it', async () => { - const old = await stopWithUnprovenClose(2) - - const sent = await send('carry on') - if (!sent.ok || !('submission' in sent.value)) { - throw new Error(JSON.stringify(sent)) - } - const id = sent.value.submission.clientMessageId - await vi.waitFor(async () => - expect( - (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) - ).toMatchObject({ - dispatchState: 'rejected', - reason: "Couldn't stop Codex from before. Send your message again to try once more." - }) - ) - expect(codex.connections).toHaveLength(1) - expect(startedWith(old, 'carry on')).toBe(false) - expect(host['sessions'].get(SESSION)?.child?.close).toBeDefined() - }) - - it('ends the record when the root exits after its close gave up, with no one asking again', async () => { - const old = await stopWithUnprovenClose(1) - const handled = vi.spyOn(host, 'handleAdapterEvent') - - old.handlers.onExit?.(new Error('codex app-server exited'), { expected: true }) - - expect(handled).toHaveBeenCalledWith( - expect.objectContaining({ cause: 'requested-close', failure: { kind: 'hostFault' } }) - ) - await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) - expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) - expect(codex.connections).toHaveLength(1) - }) - - it('joins that close first, then goes to a fresh Codex, never to the old one', async () => { - const old = await stopWithUnprovenClose(1) - expect(host['sessions'].get(SESSION)?.child?.close).toMatchObject({ cause: 'user-stop' }) - launch.resumeThreadId = THREAD - - const sent = await send('carry on') - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) - await vi.waitFor(() => expect(startedWith(codex.connections[1]!, 'carry on')).toBe(true)) - expect(startedWith(old, 'carry on')).toBe(false) - expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) - }) - - it('reports a process tree left unproven by a proven root exit, and ends the record anyway', async () => { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - throw interruptFailure('internal error') - } - const old = codex.connections.at(-1)! - // Prove root exit independently of descendant-tree cleanup. - Object.defineProperty(old, 'processTreeUnproven', { get: () => old.closed }) - - await stop() - - await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) - expect(log.entries.map((entry) => entry.fields.scope)).toContain('provider-close-after-exit') - expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ rootGone: true }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts deleted file mode 100644 index 888fd7c9217d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ /dev/null @@ -1,918 +0,0 @@ -// Unnamed Stop reaches queued sends and handed-over work whose turn has not opened yet. - -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' -import { CancelParams } from '../../../shared/rpc-contract/structured-agent-session-params' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' -import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { - THREAD_ID, - acquireReadyCodexForTest, - adapterFor, - answerWithOpenedTurn, - fakeCodex, - identityFor -} from '../../codex/codex-structured-session-adapter-fixture' -import { AgentModelCatalogStore } from '../agent-model-catalog/agent-model-catalog-store' -import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let dispatch: Mock -let cancelTurn: Mock -let closeSession: Mock> -let setOption: Mock -let acknowledgeSessionRelease: Mock< - NonNullable -> -let stopEndsSession: boolean -let log: ReturnType -let events: StructuredAgentSessionEventSink | undefined - -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) - resetHostTestOperationIds() - events = undefined - // Transport admission precedes provider acknowledgement/turn-open. - dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - cancelTurn = vi.fn(async () => ({ cancelled: true })) - closeSession = vi.fn(async () => true) - setOption = vi.fn(async () => undefined) - acknowledgeSessionRelease = vi.fn() - stopEndsSession = false - log = recordingStructuredAgentSessionLogger() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: log.logger, - store, - adapter: { - acquire: async ({ fence, spawnToken, events: sink }) => { - events = sink - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - } - } - }, - open: async () => ({ reportedOptions: { model: '' } }), - dispatch, - closeSession, - acknowledgeSessionRelease, - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - stopEndsSession: () => stopEndsSession, - answerPrompt: vi.fn(async () => undefined), - setOption - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function hostEvents(): StructuredAgentSessionEventSink { - if (!events) { - throw new Error('the host attached no event sink') - } - return events -} - -function send(text: string) { - const body = hostTestMessage(text) - const clientOperationId = hostTestOperationId() - const result = host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - return { id: clientOperationId, result } -} - -function stop(turnId?: string, clientOperationId = hostTestOperationId()) { - const fields = turnId === undefined ? {} : { turnId } - return host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields - }) - }, - ...fields - }) -} - -function pickOption(key: string, value: string) { - const fields = { key, value } - return host.setOption(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: SESSION, - fields - }) - }, - ...fields - }) -} - -async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) -} - -async function statusRows(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' ? [item.body.text] : [] - ) -} - -describe('a Stop that names no turn', () => { - it('does not let independent discovery hold an option pick, Stop, or replacement send', async () => { - const pending = Promise.withResolvers() - const codex = fakeCodex({ 'model/list': () => pending.promise }) - codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') - codex.routes['turn/steer'] = () => ({ turn: { id: 'turn-1' } }) - const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { - modelCatalog: new AgentModelCatalogStore(), - demandModelCatalog: () => pending.promise - }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor(SESSION), - fence: 1, - spawnToken: 'spawn-catalog', - options: { serviceTier: 'priority' }, - events: hostEvents() - }) - dispatch.mockImplementation((input) => adapter.dispatch(input)) - cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) - setOption.mockImplementation((input) => adapter.setOption(input)) - - const listing = adapter.readOptions({ sessionId: SESSION, fence: 1 }) - const catalogReads = codex.connections[0].calls.filter( - (call) => call.method === 'model/list' - ).length - await pickOption('serviceTier', 'priority') - const first = send('first send') - await first.result - await eventually(() => - expect(codex.connections[0].calls.some((call) => call.method === 'turn/start')).toBe(true) - ) - expect(await pickOption('model', 'gpt-next')).toMatchObject({ - ok: true, - value: { options: { model: 'gpt-next' } } - }) - expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - catalogReads - ) - - const followUp = send('follow-up') - await followUp.result - expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(false) - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - codex.connections[0].handlers.onNotification?.('turn/completed', { - threadId: THREAD_ID, - turn: { id: 'turn-1', status: 'interrupted' } - }) - codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-2') - const replacement = send('replacement') - await replacement.result - await eventually(() => - expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start') - ).toHaveLength(2) - ) - expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params - ).toMatchObject({ serviceTier: 'priority' }) - pending.resolve({ data: [], nextCursor: null }) - await expect(listing).resolves.toMatchObject({ - current: { model: 'gpt-next', serviceTier: 'priority' } - }) - }) - - // Codex can interrupt before turn start; neither reply/frame ordering should create a visible empty turn. - it.each([ - ['its end follows the answer', 'after'], - ['its end is read before the answer', 'before'], - ['no end comes', 'none'] - ])('withdraws a send whose turn Codex never started when it takes a Stop: %s', async (_, end) => { - const codex = fakeCodex() - codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) - const ended = () => - codex.connections[0].handlers.onNotification?.('turn/completed', { - threadId: THREAD_ID, - turn: { id: 'turn-1', status: 'interrupted' } - }) - codex.routes['turn/interrupt'] = () => { - if (end === 'before') { - ended() - } else if (end === 'after') { - setTimeout(ended) - } - return {} - } - const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor(SESSION), - fence: 1, - spawnToken: 'spawn-unstarted', - events: hostEvents() - }) - dispatch.mockImplementation((input) => adapter.dispatch(input)) - cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) - const first = send('first send') - await first.result - await eventually(async () => expect((await submission(first.id))?.handedOverAt).toBeDefined()) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await new Promise((resolve) => setTimeout(resolve, 20)) - await host.flushStreamedEvents(SESSION) - - expect(await submission(first.id)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - const { items } = await host.journalSnapshot(SESSION) - expect(items.filter((item) => item.body.kind === 'turn')).toEqual([]) - expect(await statusRows()).toEqual([]) - }) - - it('keeps the record of a turn Codex never started when something of it was read', async () => { - const codex = fakeCodex() - codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) - codex.routes['turn/interrupt'] = () => { - const notify = codex.connections[0].handlers.onNotification - notify?.('item/completed', { - threadId: THREAD_ID, - turnId: 'turn-1', - item: { type: 'agentMessage', id: 'message-1', text: 'partial' } - }) - notify?.('turn/completed', { - threadId: THREAD_ID, - turn: { id: 'turn-1', status: 'interrupted' } - }) - return {} - } - const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor(SESSION), - fence: 1, - spawnToken: 'spawn-unstarted-item', - events: hostEvents() - }) - dispatch.mockImplementation((input) => adapter.dispatch(input)) - cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) - const first = send('first send') - await first.result - await eventually(async () => expect((await submission(first.id))?.handedOverAt).toBeDefined()) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await host.flushStreamedEvents(SESSION) - - const { items } = await host.journalSnapshot(SESSION) - expect(items.filter((item) => item.body.kind === 'turn').map((item) => item.body)).toEqual([ - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) - ]) - // This impossible provider order guards records only; observed work cannot become unrun. - expect((await submission(first.id))?.dispatchState).not.toBe('rejected') - }) - - it('keeps the record of a turn Codex failed without starting it', async () => { - const codex = fakeCodex() - codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) - const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor(SESSION), - fence: 1, - spawnToken: 'spawn-unstarted-failed', - events: hostEvents() - }) - dispatch.mockImplementation((input) => adapter.dispatch(input)) - const first = send('first send') - await first.result - await eventually(async () => expect((await submission(first.id))?.handedOverAt).toBeDefined()) - - codex.connections[0].handlers.onNotification?.('turn/completed', { - threadId: THREAD_ID, - turn: { id: 'turn-1', status: 'failed', error: { message: 'model overloaded' } } - }) - await host.flushStreamedEvents(SESSION) - - const { items } = await host.journalSnapshot(SESSION) - expect(items.filter((item) => item.body.kind === 'turn').map((item) => item.body)).toEqual([ - expect.objectContaining({ turnId: 'turn-1' }) - ]) - }) - - it('records a provider-refused turn, then sends again without changing saved picks', async () => { - const pending = Promise.withResolvers() - const codex = fakeCodex({ 'model/list': () => pending.promise }) - codex.routes['turn/start'] = (params) => { - if (codex.connections[0].calls.filter((call) => call.method === 'turn/start').length === 1) { - throw new CodexAppServerRequestError( - 'turn/start', - -32602, - 'codex app-server turn/start failed: unknown model gpt-missing', - 'unknown model gpt-missing' - ) - } - return answerWithOpenedTurn(codex, 'turn-ok')(params) - } - const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { - modelCatalog: new AgentModelCatalogStore(), - demandModelCatalog: () => pending.promise - }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor(SESSION), - fence: 1, - spawnToken: 'spawn-bad' - }) - dispatch.mockImplementation((input) => adapter.dispatch(input)) - setOption.mockImplementation((input) => adapter.setOption(input)) - - expect(await pickOption('model', 'gpt-missing')).toMatchObject({ ok: true }) - const refused = send('first send') - await refused.result - await eventually(async () => - expect(await submission(refused.id)).toMatchObject({ - dispatchState: 'rejected', - reason: expect.stringContaining('unknown model gpt-missing'), - rejection: { kind: 'providerRejected' } - }) - ) - - expect(await pickOption('model', 'gpt-live')).toMatchObject({ ok: true }) - const retried = send('second send') - await retried.result - await eventually(() => - expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start') - ).toHaveLength(2) - ) - expect(store.getRecord(SESSION)?.options?.model).toBe('gpt-live') - await eventually(async () => - expect((await submission(retried.id))?.dispatchState).not.toBe('rejected') - ) - pending.resolve({ data: [], nextCursor: null }) - }) - - it('delivers a newly accepted message after Stop withdraws the prior send', async () => { - const first = send('first send') - await first.result - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-never-opened' }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await submission(first.id)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - expect(await statusRows()).toEqual([]) - const replacement = send('replacement send') - await replacement.result - - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(dispatch.mock.calls[1]![0].clientMessageId).toBe(replacement.id) - }) - - // Bound sinks journal turn-start before the following Stop reply, even if startup was buffered. - it("writes a taken Stop's note after the turn record the provider sent before its answer", async () => { - const first = send('first send') - await first.result - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - cancelTurn.mockImplementationOnce(async () => { - events?.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return { cancelled: true, turnId: 'turn-1' } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - const items = (await host.journalSnapshot(SESSION)).items - const record = items.find((item) => item.body.kind === 'turn') - const note = items.find((item) => isStructuredAgentSessionStopNote(item.itemId)) - expect(note?.sequence).toBeGreaterThan(record?.sequence ?? Infinity) - expect(note?.turnScope).toEqual({ kind: 'turn', turnItemId: record?.itemId }) - }) - - it('ends the turn the provider opened before answering, and withdraws nothing', async () => { - const first = send('first send') - await first.result - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - cancelTurn.mockImplementationOnce(async () => { - hostEvents().appendItem( - { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return { cancelled: true, turnId: 'turn-1' } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect((await submission(first.id))?.dispatchState).toBe('pending') - const turn = (await host.journalSnapshot(SESSION)).items.find( - (item) => item.body.kind === 'turn' - ) - expect(turn?.body).toMatchObject({ state: 'interrupted' }) - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) - - it('withdraws nothing on a refusal, even one that names the turn', async () => { - const first = send('first send') - await first.result - await eventually(async () => expect((await submission(first.id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - turnId: 'turn-never-opened', - refusal: { - detail: { text: 'no active turn to interrupt', audience: 'person' }, - turnNotRunning: true - } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect((await submission(first.id))?.dispatchState).toBe('pending') - }) - - it('answers the Stop when settling the send it took fails, and reports the failure', async () => { - const first = send('first send') - await first.result - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('the journal threw')) - cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-never-opened' }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(log.entries).toContainEqual( - expect.objectContaining({ - fields: expect.objectContaining({ scope: 'stop-settle', sessionId: SESSION }) - }) - ) - }) - - it('is a valid cancel, and only a plain Stop may omit the turn', () => { - const envelope = { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: '0'.repeat(64) - } - expect(CancelParams.safeParse({ envelope }).success).toBe(true) - expect( - CancelParams.safeParse({ envelope, prompt: { itemId: 'item-1', expectedRevision: 1 } }) - .success - ).toBe(false) - expect(CancelParams.safeParse({ envelope, scope: 'background-tasks' }).success).toBe(false) - }) - - it('interrupts a handed-over message before its turn opens, as a cancellation', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - expect( - (await host.journalSnapshot(SESSION)).items.some((item) => item.body.kind === 'turn') - ).toBe(false) - - const stopped = await stop() - - expect(stopped).toEqual({ - ok: true, - replayed: false, - fence: 1, - cursor: expect.anything(), - value: { cancelled: true } - }) - expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') - expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) - - it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { - // Hold the lane so Stop runs after acceptance but before handover. - const release = holdLane(host, SESSION) - const { id, result } = send('hello') - const stopped = stop() - release() - await result - - expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - expect(cancelTurn).not.toHaveBeenCalled() - await host.flushStreamedEvents(SESSION) - expect(dispatch).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it('withdraws a send still on its way in, which the host takes first', async () => { - const { id, result } = send('hello') - const stopped = stop() - - expect(await result).toMatchObject({ ok: true }) - expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - await host.flushStreamedEvents(SESSION) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('ends the child when the provider could not interrupt the message still in flight', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' } } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect((await submission(id))?.dispatchState).toBe('rejected') - expect(await statusRows()).toEqual([]) - }) - - it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { - detail: { text: 'no active turn to interrupt', audience: 'person' }, - turnNotRunning: true - } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(closeSession).not.toHaveBeenCalled() - expect(await statusRows()).toEqual(["Codex didn't stop: no active turn to interrupt."]) - }) - - it('ends the child when the provider never answered the interrupt', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - // Codex answers interrupts with turn end; missing end must time out. - cancelTurn.mockRejectedValueOnce(new Error('codex app-server turn/interrupt exceeded 30000ms')) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect((await submission(id))?.dispatchState).toBe('rejected') - expect(await statusRows()).toEqual([]) - }) - - it('says the agent did not stop, in its words, when it could not interrupt and the child end is unproven', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' } } - }) - closeSession.mockResolvedValueOnce(false) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(log.entries).toContainEqual( - expect.objectContaining({ - fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) - }) - ) - expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) - }) - - it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockRejectedValueOnce(new Error('codex app-server turn/interrupt exceeded 30000ms')) - acknowledgeSessionRelease.mockImplementationOnce(() => { - throw new Error('route release failed') - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(log.entries).toContainEqual( - expect.objectContaining({ - fields: expect.objectContaining({ scope: 'exit-wind-down', sessionId: SESSION }) - }) - ) - expect((await submission(id))?.dispatchState).toBe('rejected') - expect(await statusRows()).toEqual([]) - }) - - it('says the Stop is unconfirmed, not that nothing ran, when neither the interrupt nor the child end is proven', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockRejectedValueOnce(new Error('codex app-server turn/interrupt exceeded 30000ms')) - closeSession.mockResolvedValueOnce(false) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) - }) - - it('says the agent had no turn to stop when it had none', async () => { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(await statusRows()).toEqual(['Codex had no turn running to stop.']) - }) - - it('stops nothing when it reuses the id of a Stop the host already ran', async () => { - const operationId = hostTestOperationId() - expect(await stop(undefined, operationId)).toMatchObject({ ok: true, replayed: false }) - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - - // Reusing an operation ID must replay the original Stop, not target new work. - expect(await stop(undefined, operationId)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(cancelTurn).not.toHaveBeenCalled() - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - }) - - // Claude echo acceptance can precede its own turn row by one sink write. - async function acceptedWithTurnRowUnlanded(): Promise { - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 } - }) - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - } - - it('interrupts a turn whose accepted send is in the journal before its row lands', async () => { - await acceptedWithTurnRowUnlanded() - // Stop’s journal read must follow the simultaneously queued turn row. - events!.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn-lifecycle:turn-2' }, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-2', state: 'running' } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - expect(await statusRows()).toContain('Cancellation requested.') - }) - - it('is a quiet no-op with nothing in flight', async () => { - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(cancelTurn).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) -}) - -describe('a Stop that names its turn, as an older client sends it', () => { - it('is a quiet no-op, reaching no agent, when that turn is already over', async () => { - expect(await stop('turn-1')).toMatchObject({ - ok: true, - value: { turnId: 'turn-1', cancelled: false } - }) - expect(cancelTurn).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it('reaches the provider with that turn, and writes no row when it stopped nothing', async () => { - events!.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - - expect(await stop('turn-1')).toMatchObject({ - ok: true, - value: { turnId: 'turn-1', cancelled: false } - }) - expect(cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId: 'turn-1' })) - expect(await statusRows()).toEqual([]) - }) - - /** Hold host delivery so intervening operations queue before handover. */ - function queueOnHost(): { id: string; release: () => void } { - const release = holdLane(host, SESSION) - return { id: send('hello').id, release } - } - - it('reports success with no row when it withdrew a queued message and the turn had ended', async () => { - const queued = queueOnHost() - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - - const stopped = stop('turn-1') - queued.release() - expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) - expect(await statusRows()).toEqual([]) - }) - - it('reports success with no row when the provider refused a turn that had ended', async () => { - const queued = queueOnHost() - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'no such turn', audience: 'person' } } - }) - - const stopped = stop('turn-1') - queued.release() - expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(await submission(queued.id)).toMatchObject({ dispatchState: 'rejected' }) - expect(await statusRows()).toEqual([]) - }) -}) - -describe('a Stop on a provider whose Stop ends its session', () => { - async function handedOver(): Promise { - const { id, result } = send('hello') - await result - await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) - } - - /** Drain through Stop’s queued second step. */ - function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) - } - - it('ends the child after the cancel even when the provider refused it, and says only that it was asked', async () => { - stopEndsSession = true - await handedOver() - cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } - }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) - - it('ends the child and says it was asked when the provider declined a Stop naming the live turn', async () => { - stopEndsSession = true - await handedOver() - events!.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: SESSION, recordId: 'turn:turn-1' }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) - - it('keeps the child of a provider whose Stop is not a session boundary', async () => { - await handedOver() - cancelTurn.mockResolvedValueOnce({ cancelled: true }) - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(closeSession).not.toHaveBeenCalled() - }) - - it('leaves the child alone when the Stop named a turn that is no longer live', async () => { - stopEndsSession = true - await handedOver() - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: false } }) - await laneDrained() - - expect(cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId: 'turn-1' })) - expect(closeSession).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it("ends the child when the provider's cancel of a Stop naming a turn no longer live fails", async () => { - stopEndsSession = true - await handedOver() - cancelTurn.mockRejectedValueOnce(new Error('control request lost')) - - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) - - it('ends the child when the provider took a Stop naming a turn that is no longer live', async () => { - stopEndsSession = true - await handedOver() - cancelTurn.mockResolvedValueOnce({ cancelled: true }) - - expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts deleted file mode 100644 index 62e856a3a39a..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { - createRestTestRig, - restTestSend, - type RestTestRig -} from './structured-agent-session-rest-test-rig' -import { - CREATE_TEST_CALLER as CALLER, - createTestParams, - stopCreatedChat -} from './structured-agent-session-create-test-fixture' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestMessage, - hostTestOperationId -} from './structured-agent-session-host-test-data' -import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' -import { - openTestJournalHostDatabase, - loadTestJournal -} from '../agent-session-journal/journal-host-database-test-support' -import { readPersistedTestAgentSessionStore } from '../../runtime/agent-session-record-store-test-harness' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' -import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' -import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../../shared/native-chat-stopped-before-start' - -let rig: RestTestRig -beforeEach(async () => { - rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) -}) -afterEach(async () => { - await rig.dispose() - vi.restoreAllMocks() -}) - -function firstMessage() { - return { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening message') } -} - -function abortableAcquisition() { - const entered = Promise.withResolvers() - rig.adapter.acquire.mockImplementationOnce( - ({ signal }) => - new Promise((_resolve, reject) => { - entered.resolve() - const abort = () => reject(new AgentSessionPreSpawnError(signal?.reason)) - if (signal?.aborted) { - abort() - } else { - signal?.addEventListener('abort', abort, { once: true }) - } - }) - ) - return entered.promise -} - -it('commits the record, tab, operation and first message together and returns while acquire is unresolved', async () => { - const first = firstMessage() - const params = createTestParams(first, { surfaceTabId: 'chat-create-tab' }) - const entered = abortableAcquisition() - const observed: unknown[] = [] - rig.store.onFirstRecord(() => - observed.push({ - record: rig.store.getRecord(SESSION), - tab: rig.store.getSessionTabId(SESSION), - operation: rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId), - submission: loadTestJournal(rig.root, SESSION)?.state.submissions.get(first.clientMessageId) - }) - ) - const publishTab = vi.fn(async () => { - expect(rig.adapter.acquire).not.toHaveBeenCalled() - expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - reservedSpawnToken: null - }) - }) - const result = await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) - expect(result).toMatchObject({ - ok: true, - fence: 1, - value: { - tabId: 'chat-create-tab', - firstMessage: { clientMessageId: first.clientMessageId, dispatchState: 'pending' }, - page: { - submissions: [{ clientMessageId: first.clientMessageId, dispatchState: 'pending' }], - items: expect.arrayContaining([expect.objectContaining({ body: first.body })]) - } - } - }) - expect(observed).toMatchObject([ - { - tab: 'chat-create-tab', - operation: { outcome: { status: 'succeeded' } }, - submission: { clientMessageId: first.clientMessageId }, - record: { lease: { claimStatus: 'released' } } - } - ]) - expect(publishTab).toHaveBeenCalledOnce() - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) - await entered - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) -}) - -it('creates a blank chat at rest without acquiring and persists its launch arguments', async () => { - const resolveLaunchArgs = vi.fn(() => ['--test-flag']) - const resolveLaunchEnv = vi.fn(() => ({ SAFE_FLAG: 'yes' })) - await rig.dispose() - rig = await createRestTestRig({ - resolveLaunchArgs, - resolveLaunchEnv, - idleSweep: { intervalMs: 60_000 } - }) - expect(await rig.host.create(CALLER, createTestParams())).toMatchObject({ - ok: true, - value: { page: { submissions: [] } } - }) - expect(rig.store.getRecord(SESSION)).toMatchObject({ - launchArgs: ['--test-flag'], - lease: { - claimStatus: 'released', - runtimeFence: 1, - ownerProcess: null, - reservedSpawnToken: null - } - }) - expect(resolveLaunchEnv).toHaveBeenCalledOnce() - expect(rig.adapter.acquire).not.toHaveBeenCalled() -}) - -it('keeps delivery behind runtime tab publication even after the message is committed', async () => { - const first = firstMessage() - const held = Promise.withResolvers() - const release = Promise.withResolvers() - const entered = abortableAcquisition() - let answered = false - const creating = rig.host - .create(CALLER, createTestParams(first), { - firstMessage: first, - publishTab: async () => { - held.resolve() - await release.promise - } - }) - .then((result) => { - answered = true - return result - }) - await held.promise - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(rig.store.getRecord(SESSION)).not.toBeNull() - expect(rig.adapter.acquire).not.toHaveBeenCalled() - expect(answered).toBe(false) - release.resolve() - expect(await creating).toMatchObject({ ok: true }) - await entered - await stopCreatedChat(rig.host) -}) - -it('an immediate Stop cancels the queued first message before acquire and replay never redelivers it', async () => { - const first = firstMessage() - const params = createTestParams(first) - let stopping: ReturnType | undefined - expect( - await rig.host.create(CALLER, params, { - firstMessage: first, - publishTab: async () => { - stopping = stopCreatedChat(rig.host) - } - }) - ).toMatchObject({ ok: true }) - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() - expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ - ok: true, - replayed: true, - value: { - page: { - submissions: [{ clientMessageId: first.clientMessageId, rejection: { kind: 'cancelled' } }] - } - } - }) - expect( - rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() - ).toMatchObject({ event: { reason: 'user-stop' } }) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() -}) - -it('holds the created first message until the child proves its start, then hands it over once', async () => { - const first = firstMessage() - const spawn = rig.adapter.acquire.getMockImplementation()! - let generation = '' - const holdStart = rig.heldStart(spawn) - rig.adapter.acquire.mockImplementationOnce(async (input) => { - const child = await holdStart(input) - generation = child.acquisitionGeneration ?? '' - return child - }) - expect( - await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - const held = (await rig.host.journalSnapshot(SESSION)).submissions - expect(held).toEqual([expect.objectContaining({ clientMessageId: first.clientMessageId })]) - expect(held[0].handedOverAt).toBeUndefined() - - await rig.proveStart({ - sessionId: SESSION, - fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: generation - }) - - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(rig.adapter.dispatch).toHaveBeenCalledWith(expect.objectContaining({ body: first.body })) - expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(rig.adapter.dispatch).toHaveBeenCalledOnce() -}) - -it('Stop while the child proves its start withdraws the created first message once, unsent', async () => { - const first = firstMessage() - const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) - expect( - await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) - - expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) - - const submissions = (await rig.host.journalSnapshot(SESSION)).submissions - expect(submissions).toEqual([ - expect.objectContaining({ - clientMessageId: first.clientMessageId, - dispatchState: 'rejected', - rejection: expect.objectContaining({ kind: 'cancelled' }) - }) - ]) - expect(submissions[0].handedOverAt).toBeUndefined() - expect(rig.adapter.dispatch).not.toHaveBeenCalled() -}) - -it('Stop aborts a handshake outside the lane and the next send sends only its own text', async () => { - const first = firstMessage() - const params = createTestParams(first) - const entered = abortableAcquisition() - expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ ok: true }) - await entered - expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ - ok: true, - replayed: true - }) - const next = restTestSend('next message', rig.store.getRecord(SESSION)?.lease.runtimeFence) - expect(await rig.host.send(CALLER, next)).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(rig.adapter.dispatch).toHaveBeenCalledWith(expect.objectContaining({ body: next.body })) - const snapshot = await rig.host.journalSnapshot(SESSION) - expect(snapshot.submissions).toHaveLength(2) - expect( - snapshot.submissions.find(({ clientMessageId }) => clientMessageId === first.clientMessageId) - ?.rejection - ).toMatchObject({ kind: 'cancelled' }) -}) - -it('replays the stopped first message even after its row falls outside the hydration page', async () => { - const first = firstMessage() - const params = createTestParams(first) - let stopping: ReturnType | undefined - expect( - await rig.host.create(CALLER, params, { - firstMessage: first, - publishTab: async () => { - stopping = stopCreatedChat(rig.host) - } - }) - ).toMatchObject({ ok: true }) - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal - if (!journal) { - throw new Error('created chat has no journal') - } - for (let ordinal = 1; ordinal <= AGENT_SESSION_HISTORY_MAX_LIMIT + 1; ordinal += 1) { - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'later-history', ordinal }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `later ${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const append = vi.spyOn(journal, 'appendSubmission') - const replay = await rig.host.create(CALLER, params, { firstMessage: first }) - if (!replay.ok) { - throw new Error(`create replay refused: ${replay.refusal.code}`) - } - expect(replay.replayed).toBe(true) - expect(replay.value.page.hasOlder).toBe(true) - expect(replay.value.page.items).not.toEqual( - expect.arrayContaining([expect.objectContaining({ body: first.body })]) - ) - expect(replay.value.page.submissions).toEqual([]) - expect(replay.value.firstMessage).toMatchObject({ - clientMessageId: first.clientMessageId, - dispatchState: 'rejected', - rejection: { kind: 'cancelled' } - }) - expect(append).not.toHaveBeenCalled() - expect(rig.adapter.acquire).not.toHaveBeenCalled() - expect(rig.adapter.dispatch).not.toHaveBeenCalled() -}) - -it('admits Stop with the create-returned fence after acquisition advances it and keeps the stopped row', async () => { - const first = firstMessage() - const entered = abortableAcquisition() - const created = await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) - if (!created.ok) { - throw new Error(`create refused: ${created.refusal.code}`) - } - expect(created.fence).toBe(1) - await entered - expect(rig.store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(created.fence) - const stopping = stopCreatedChat(rig.host, SESSION, created.fence) - let stopped: Awaited | undefined - void stopping.then((outcome) => { - stopped = outcome - }) - try { - await vi.waitFor(() => expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } })) - const snapshot = await rig.host.journalSnapshot(SESSION) - expect(snapshot.submissions).toMatchObject([ - { - clientMessageId: first.clientMessageId, - dispatchState: 'rejected', - rejection: { kind: 'cancelled' } - } - ]) - expect( - projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) - ).toMatchObject([ - { role: 'user', blocks: first.body.blocks }, - { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } - ]) - expect( - rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() - ).toMatchObject({ event: { reason: 'user-stop' } }) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - } finally { - rig.host.collaboratorsForTests().runtimeState.acquireAborts.abort(SESSION, 'test cleanup') - await stopping - } -}) - -it.each(['journal_rows', 'agent_session_operations'])( - 'rolls back all create state when %s refuses its write', - async (table) => { - const first = firstMessage() - const params = createTestParams(first) - const db = openTestJournalHostDatabase(rig.root).db - const when = table === 'journal_rows' ? `WHEN NEW.row_json LIKE '%"kind":"submission"%'` : '' - db.exec( - `CREATE TRIGGER refuse_create BEFORE INSERT ON ${table} ${when} BEGIN SELECT RAISE(ABORT, 'simulated write failure'); END` - ) - const publishTab = vi.fn(async () => {}) - expect( - await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) - ).toMatchObject({ ok: false }) - expect(rig.store.getRecord(SESSION)).toBeNull() - expect(rig.store.getSessionTabId(SESSION)).toBeNull() - expect(rig.store.listOperationRows()).toEqual([]) - expect((await readPersistedTestAgentSessionStore(rig.root)).records).toEqual({}) - expect(loadTestJournal(rig.root, SESSION)?.state.submissions.size).toBe(0) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.adapter.acquire).not.toHaveBeenCalled() - expect(publishTab).not.toHaveBeenCalled() - db.exec('DROP TRIGGER refuse_create') - const entered = abortableAcquisition() - expect( - await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) - ).toMatchObject({ ok: true }) - await entered - await stopCreatedChat(rig.host) - } -) - -it('answers a journal founding failure as a refusal before publishing any create state', async () => { - const db = openTestJournalHostDatabase(rig.root).db - db.exec( - `CREATE TRIGGER refuse_epoch BEFORE INSERT ON journal_rows WHEN NEW.row_json LIKE '%"kind":"epoch"%' BEGIN SELECT RAISE(ABORT, 'simulated journal open failure'); END` - ) - const first = firstMessage() - expect( - await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) - ).toMatchObject({ ok: false, refusal: { code: 'agent_session_journal_unreadable' } }) - expect(rig.store.listRecords()).toEqual([]) - expect(rig.store.listOperationRows()).toEqual([]) - expect(rig.adapter.acquire).not.toHaveBeenCalled() -}) - -it('retries tab publication from the committed message without acquiring early or appending again', async () => { - const first = firstMessage() - const params = createTestParams(first) - expect( - await rig.host.create(CALLER, params, { - firstMessage: first, - publishTab: async () => { - throw new Error('tab unavailable') - } - }) - ).toMatchObject({ ok: false, refusal: { details: { reason: 'tabUnconfirmed' } } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() - const append = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') - const entered = abortableAcquisition() - expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ - ok: true, - replayed: true - }) - expect(append).not.toHaveBeenCalled() - await entered - await stopCreatedChat(rig.host) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 783eb875f90c..621b8deafe3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -125,8 +125,9 @@ export async function beginSubmissionHandover( } 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 () => { - await resolveHandover(ctx, clientMessageId, outcome) if (outcome?.state === 'accepted' || outcome?.state === 'admitted') { await ctx.recordRanOptions?.({ message: submission.options, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 8f1be2a71992..26f3edbde089 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -163,9 +163,15 @@ export function cancelStructuredAgentSessionTurn( const stoppedPrePrompt = !prompt && params.turnId === undefined ? abortProviderWaitForStop(context, caller, params.envelope, plan) - : false + : null // Only live-turn prompt Cancel reaches chat Stop; it shares the untargeted Stop path. - const stopped = prompt ? { envelope: params.envelope } : { ...params, stoppedPrePrompt } + const stopped = prompt + ? { envelope: params.envelope } + : { + ...params, + stoppedPrePrompt: stoppedPrePrompt?.reached === true, + stoppedSend: stoppedPrePrompt?.handedOver === true + } return mutateWithChatStop(context, caller, stopped, plan, (ctx, stop) => prompt ? cancelStructuredAgentSessionPrompt( @@ -183,7 +189,7 @@ function abortProviderWaitForStop( caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, plan: MutationPlan -): boolean { +): { reached: boolean; handedOver: boolean } | null { const { store } = context.deps if ( !agentSessionMutationAdmitsNow({ @@ -194,7 +200,7 @@ function abortProviderWaitForStop( now: context.now }) ) { - return false + return null } return context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts deleted file mode 100644 index edf3c55e107d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionStatusEvent, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -import { - adapter, - attach, - attachParams, - CALLER, - ensureParams, - envelope, - hostTestState, - replaceHostTestState, - seedApproval -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let releaseAcquisition: Mock> -let dispatch: Mock -let cancelTurn: Mock -let answerPrompt: Mock -let setOption: Mock - -beforeEach(() => { - ;({ - root, - store, - host, - acquire, - releaseAcquisition, - dispatch, - cancelTurn, - answerPrompt, - setOption - } = hostTestState()) -}) - -describe('attach', () => { - it('founds a client-location floating chat with the host folder and keeps it on replay', async () => { - const resolveWorkspacePath = vi.fn(async () => '/host/original-folder') - host.deps.resolveWorkspacePath = resolveWorkspacePath - const params = attachParams({ - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: FLOATING_TERMINAL_WORKTREE_ID, - workspaceKind: 'folder' - } - }) - - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.launchDirectory).toBe('/host/original-folder') - resolveWorkspacePath.mockResolvedValue('/host/changed-folder') - - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(store.getRecord(SESSION)?.launchDirectory).toBe('/host/original-folder') - expect(resolveWorkspacePath).toHaveBeenCalledTimes(1) - }) - - it('reserves the lease, spawns through the adapter, and opens the journal', async () => { - const result = await host.attach(CALLER, attachParams()) - expect(result).toMatchObject({ ok: true, replayed: false }) - const record = store.getRecord(SESSION) - expect(record?.lease.ownerProcess?.pid).toBe(4242) - expect(record?.lease.handoffStage).toBeNull() - }) - - it('refuses a payload the client fingerprinted wrong', async () => { - const params = attachParams() - const result = await host.attach(CALLER, { - ...params, - envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } - }) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_conflict' } - }) - }) - - it('refuses a provider handle that belongs to a different provider', async () => { - const params = attachParams({ - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } - }) - - expect(await host.attach(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(store.getRecord(SESSION)).toBeNull() - }) - - it('refuses a second create against a live session', async () => { - await attach() - expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) - }) - - it('replays a retried attach instead of reserving a second owner', async () => { - const params = attachParams() - await host.attach(CALLER, params) - const retry = await host.attach(CALLER, params) - expect(retry).toMatchObject({ ok: true, replayed: true }) - }) - - it('retires a failed proved acquisition before admitting a fresh operation', async () => { - const acquire = vi - .fn() - .mockImplementationOnce(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: 'stale-link', - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence + 1, - observedAt: NOW - }, - acquisitionGeneration: 'generation-stale' - })) - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${fence}` - })) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { ...adapter(), acquire }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const params = attachParams() - - // Host storage failure must not blame the provider. - const refused = { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { ownerVerdict: 'exited' }, - message: "Codex couldn't restart. Send your message to try again.", - ownerVerdict: 'exited' - } - } - expect(await host.attach(CALLER, params)).toEqual(refused) - expect(await host.attach(CALLER, params)).toEqual(refused) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await host.attach(CALLER, ensureParams(releasedFence))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) - }) - - it('reaps an acquisition when process identity commit fails', async () => { - vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed')) - - await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ - ok: false, - refusal: { - message: "Codex couldn't restart. Send your message to try again.", - ownerVerdict: 'exited' - } - }) - - expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) - }) - - it('drains writes captured by the old journal before acquiring its replacement', async () => { - const record = await attach() - const events = acquire.mock.calls[0]?.[0].events - const oldJournal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const appendGate = Promise.withResolvers() - const originalAppend = oldJournal.appendItem.bind(oldJournal) - const append = vi.spyOn(oldJournal, 'appendItem').mockImplementationOnce(async (...args) => { - await appendGate.promise - return originalAppend(...args) - }) - events?.appendItem( - { provider: 'orca', clientMessageId: 'old-journal-write' }, - { kind: 'status', text: 'old journal write' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) - const released = await store.evictProvenDeadOwner({ - sessionId: SESSION, - expectedFence: record?.lease.runtimeFence ?? 1, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - - const replacement = host.attach(CALLER, ensureParams(released.lease.runtimeFence)) - await new Promise((resolve) => setImmediate(resolve)) - expect(acquire).toHaveBeenCalledTimes(1) - - appendGate.resolve() - await expect(replacement).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - }) -}) - -describe('cancel', () => { - /** A named Stop needs an existing turn; unknown turns are late no-ops. */ - async function runningTurn(): Promise { - await attach() - acquire.mock.calls - .at(-1)![0] - .events!.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - } - - it('records the request acknowledgement as a status item keyed by the operation id', async () => { - await runningTurn() - const result = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const note = page.ok ? page.page.items.find((item) => item.body.kind === 'status') : undefined - expect(note?.body).toMatchObject({ - kind: 'status', - text: 'Cancellation requested.' - }) - expect(JSON.stringify(note?.body)).not.toContain('turn-1') - }) - - it('ends the child when the turn runs on after an unanswered interrupt', async () => { - await runningTurn() - cancelTurn.mockRejectedValueOnce(new Error('no answer')) - const result = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) - }) - - it('never interrupts twice on a replay', async () => { - await runningTurn() - const params = { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - } - await host.cancel(CALLER, params) - expect(await host.cancel(CALLER, params)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(cancelTurn).toHaveBeenCalledTimes(1) - }) - - it.each([ - ['a missing prompt item', { itemId: 'missing-item', expectedRevision: 1 }], - ['a stale prompt revision', { itemId: 'seeded', expectedRevision: 2 }] - ])('refuses %s before interrupting the provider', async (_case, requestedPrompt) => { - await attach() - const prompt = await seedApproval() - const strictPrompt = { - ...requestedPrompt, - ...(requestedPrompt.itemId === 'seeded' ? { itemId: prompt.itemId } : {}) - } - const fields = { turnId: 'turn-1', prompt: strictPrompt } - - expect( - await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields), - ...fields - }) - ).toMatchObject({ ok: false }) - expect(cancelTurn).not.toHaveBeenCalled() - }) - - it('refuses cancellation after an answer has already resolved the prompt', async () => { - await attach() - const prompt = await seedApproval() - const answer = { - itemId: prompt.itemId, - expectedRevision: prompt.revision, - optionId: 'allow' - } - await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:approval', answer), - kind: 'approval', - ...answer - }) - const fields = { - turnId: 'turn-1', - prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } - } - - expect( - await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields), - ...fields - }) - ).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_item_revision_stale' } - }) - expect(cancelTurn).not.toHaveBeenCalled() - }) - - it('answers a strict prompt interruption that throws once saved from its receipt, the same on a retry, and never retries it', async () => { - await attach() - const prompt = await seedApproval() - cancelTurn.mockRejectedValueOnce(new Error('interrupt receipt lost')) - const fields = { - turnId: 'turn-1', - prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } - } - const params = { - envelope: envelope('agentSession.cancel', fields), - ...fields - } - - // The durable receipt survives interrupt failure; retry must not repeat the provider effect. - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const answered = await host.cancel(CALLER, params) - expect(answered).toMatchObject({ ok: true }) - expect(await host.cancel(CALLER, params)).toEqual({ ...answered, replayed: true }) - expect(cancelTurn).toHaveBeenCalledTimes(1) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const bodies = page.ok ? page.page.items.map((item) => item.body) : [] - expect(bodies).toEqual([ - expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }), - expect.objectContaining({ kind: 'status', text: 'Cancellation was not confirmed.' }) - ]) - }) -}) - -describe('respondToPrompt', () => { - it('commits the answer before the provider callback', async () => { - await attach() - const prompt = await seedApproval() - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:approval', fields), - kind: 'approval', - ...fields - }) - expect(result).toMatchObject({ - ok: true, - value: { resolution: { state: 'resolved', selectedOptionId: 'allow' } } - }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) - - it("keeps a subagent's approval the subagent's once the user answers it", async () => { - // Answer revisions omit producer identity and must retain the asker. - await attach() - const child = { agentId: 'thread-child', producerKind: 'agent' as const } - const identity = { - provider: 'codex' as const, - threadId: 'thread-child', - turnId: 'c', - ordinal: 1 - } - acquire.mock.calls.at(-1)?.[0].events?.appendItem( - identity, - { - kind: 'approval', - title: 'Run ls?', - detail: null, - options: [{ id: 'allow', label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(identity) - const fields = { itemId, expectedRevision: 1, optionId: 'allow' } - - await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:approval', fields), - kind: 'approval', - ...fields - }) - - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const answered = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null - expect(answered).toMatchObject({ - revision: 2, - body: { resolution: { state: 'resolved' } }, - ...child - }) - }) - - it('tells a second answer with the same choice that it holds, and asks the provider once', async () => { - await attach() - const prompt = await seedApproval() - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } - const first = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:approval', fields), - kind: 'approval', - ...fields - }) - // A different operation repeats the choice, as another device or lost-reply retry would. - const second = await host.respondToPrompt( - { callerKey: 'client-2' }, - { - envelope: envelope('agentSession.respondTo:approval', fields), - kind: 'approval', - ...fields - } - ) - expect(first.ok).toBe(true) - expect(second).toEqual({ ...first, replayed: false }) - expect(second).toMatchObject({ - ok: true, - value: { resolution: { selectedOptionId: 'allow', resolvedBy: 'client-1' } } - }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) - - it('refuses an option the prompt does not offer', async () => { - await attach() - const prompt = await seedApproval() - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } - expect( - await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:approval', fields), - kind: 'approval', - ...fields - }) - ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) - expect(answerPrompt).not.toHaveBeenCalled() - }) - - it("does not turn a refused answer into another client's successful answer", async () => { - await attach() - const prompt = await seedApproval() - const rejectedFields = { - itemId: prompt.itemId, - expectedRevision: prompt.revision, - optionId: 'deny' - } - const rejected = { - envelope: envelope('agentSession.respondTo:approval', rejectedFields), - kind: 'approval' as const, - ...rejectedFields - } - await host.respondToPrompt(CALLER, rejected) - - const acceptedFields = { ...rejectedFields, optionId: 'allow' } - await host.respondToPrompt( - { callerKey: 'client-2' }, - { - envelope: envelope('agentSession.respondTo:approval', acceptedFields), - kind: 'approval', - ...acceptedFields - } - ) - - // Refusals record nothing; retry evaluates the now-changed prompt. - expect(await host.respondToPrompt(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_item_revision_stale' } - }) - expect(answerPrompt).toHaveBeenCalledOnce() - }) - - it('keeps the answer and reports it undelivered when the provider callback throws', async () => { - await attach() - const prompt = await seedApproval() - answerPrompt.mockImplementationOnce(async ({ commit }) => { - await commit() - throw new Error('pipe closed') - }) - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:approval', fields), - kind: 'approval', - ...fields - }) - expect(result.ok).toBe(true) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const statusId = agentJournalItemKey({ - provider: 'orca', - clientMessageId: `${prompt.itemId}#delivery` - }) - expect(page.ok && page.page.items.some((entry) => entry.itemId === statusId)).toBe(true) - }) -}) - -describe('setOption', () => { - it('saves a next-message pick without changing the provider', async () => { - await attach() - setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) - const fields = { key: 'model', value: 'gpt-5' } - const params = { - envelope: envelope('agentSession.setOption', fields), - ...fields - } - const result = await host.setOption(CALLER, params) - expect(result).toMatchObject({ - ok: true, - value: { ...fields, options: { model: 'gpt-5' } } - }) - expect(await host.setOption(CALLER, params)).toMatchObject({ - ok: true, - replayed: true, - value: { ...fields, options: { model: 'gpt-5' } } - }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5' }) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.items).toHaveLength(0) - }) - - it('replays the accepted pick without contacting the provider', async () => { - await attach() - setOption.mockRejectedValueOnce(new Error('reply lost')) - const fields = { key: 'model', value: 'gpt-5' } - const params = { - envelope: envelope('agentSession.setOption', fields), - ...fields - } - - expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true }) - expect(await host.setOption(CALLER, params)).toMatchObject({ - ok: true, - replayed: true - }) - expect(setOption).not.toHaveBeenCalled() - }) -}) - -describe('restart', () => { - /** Reopen real directories with a new host/store and unreconciled leases. */ - async function reboot( - probeOwner: (record: AgentSessionRecord) => Promise, - adapterOverrides: Partial = {}, - stopOwnerProcess?: StructuredAgentSessionHostDeps['stopOwnerProcess'] - ) { - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { ...adapter(), ...adapterOverrides }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-b', - probeOwner, - ...(stopOwnerProcess ? { stopOwnerProcess } : {}), - now: () => NOW - }) - replaceHostTestState({ store, host }) - } - - /** Recover the live fence from a dead-generation refusal. */ - async function staleFenceFrom(held: number): Promise { - const refused = await host.attach(CALLER, ensureParams(held)) - if (refused.ok) { - throw new Error('a fence from the previous host generation was accepted') - } - expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') - const current = refused.refusal.currentFence - expect(current).toBeGreaterThan(held) - return current ?? 0 - } - - it('adjudicates the leases it loaded before deciding who may write', async () => { - const before = await attach() - const held = before?.lease.runtimeFence ?? 0 - await reboot(async () => ({ outcome: 'pid-absent' })) - - const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) - expect(reattached).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) - }) - - it('restores durable journals for read-only history without acquiring a provider', async () => { - await attach() - const body = hostTestMessage('persisted conversation') - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) - acquire.mockClear() - const listRecords = vi.spyOn(store, 'listRecords') - // Childless restored chats reach live lists only through restore publication. - const listed: AgentSessionStatusEvent[] = [] - host.subscribeStatus({ id: 'session-list', emit: (event) => listed.push(event) }) - - await host.restoreReadableSessions() - expect(listed.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) - }) - const restoreReads = listRecords.mock.calls.length - await host.restoreReadableSessions() - - expect(host.listSessionTabs()).toEqual([ - { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } - ]) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history.ok && history.page.items).not.toHaveLength(0) - expect(acquire).not.toHaveBeenCalled() - expect(listRecords).toHaveBeenCalledTimes(restoreReads) - }) - - it('clears a stale conflicted recovery at restart, and reacquires the native owner on the next start', async () => { - await attach() - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { - ...record.lease, - // Legacy terminal ownership loads conflicted. - claimStatus: 'conflicted', - handoffStage: 'recovering' - } - })) - await reboot(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() - - await host.restoreReadableSessions() - // Startup recovers the lease; work must still start the child. - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - claimStatus: 'live', - handoffStage: null, - handoffOperationId: null - }) - expect(host.handoffStatus(SESSION)).toMatchObject({ - owner: 'native', - phase: 'idle', - stage: null - }) - }) - - it('answers native for a chat whose start is still in flight', async () => { - await attach() - await reboot(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() - const started = Promise.withResolvers() - const release = Promise.withResolvers() - const settled = acquire.getMockImplementation() - if (!settled) { - throw new Error('missing acquire implementation') - } - acquire.mockImplementationOnce(async (input) => { - started.resolve() - await release.promise - return settled(input) - }) - - const start = host.attach( - CALLER, - ensureParams(store.getRecord(SESSION)?.lease.runtimeFence ?? 0) - ) - await started.promise - const claimMidStart = store.getRecord(SESSION)?.lease.claimStatus - const status = host.handoffStatus(SESSION) - release.resolve() - await start - - expect(claimMidStart).toBe('reserved') - expect(status).toMatchObject({ owner: 'native' }) - }) - - it('reads stored ownership even when this host cannot start the provider', async () => { - await attach() - - await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false }) - expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) - }) - - it('releases a session whose owner can never be probed, signalling nothing, and starts over', async () => { - await attach() - const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const stopOwnerProcess = vi.fn() - await reboot( - async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' }), - {}, - stopOwnerProcess - ) - acquire.mockClear() - - expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledOnce() - // Unverified PIDs may belong to unrelated processes. - expect(stopOwnerProcess).not.toHaveBeenCalled() - }) - - it('does not remember a failed adjudication as done', async () => { - const before = await attach() - const held = before?.lease.runtimeFence ?? 0 - const probe = vi - .fn<(record: AgentSessionRecord) => Promise>() - .mockRejectedValueOnce(new Error('probe exploded')) - .mockResolvedValue({ outcome: 'pid-absent' }) - await reboot(probe) - - await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') - const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) - expect(reattached).toMatchObject({ ok: true }) - expect(probe).toHaveBeenCalledTimes(2) - }) -}) - -describe('subscribe', () => { - it('opens with a snapshot and then streams cursor-qualified batches', async () => { - await attach() - const events: AgentSessionSubscribeEvent[] = [] - const dispose = await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - const body = hostTestMessage('add a retry') - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - - expect(events[0]?.type).toBe('snapshot') - const batches = events.filter((event) => event.type === 'batch') - expect(batches.length).toBeGreaterThan(0) - const last = batches.at(-1) - expect(last?.type === 'batch' && last.batch.cursor.sequence).toBeGreaterThan(0) - - dispose() - expect(events.at(-1)?.type).toBe('end') - }) - - it('resumes from a client cursor with only the rows it missed', async () => { - await attach() - const body = hostTestMessage('add a retry') - const first = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - if (!first.ok) { - throw new Error(`expected a send, got ${first.refusal.code}`) - } - - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-2', - sessionId: SESSION, - emit: (event) => events.push(event), - cursor: first.cursor - }) - expect(events[0]).toMatchObject({ type: 'batch' }) - expect(events[0]).not.toHaveProperty('handoff') - - const second = hostTestMessage('and a timeout') - await host.send(CALLER, { - envelope: envelope('agentSession.send', { body: second }), - body: second - }) - expect(events.some((event) => event.type === 'batch')).toBe(true) - expect(events.some((event) => event.type === 'snapshot')).toBe(false) - }) - - it('drops a failed transport without aborting the mutation or other subscribers', async () => { - await attach() - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'dead-sub', - sessionId: SESSION, - emit: () => { - throw new Error('socket closed') - } - }) - await host.subscribe({ - id: 'live-sub', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - const body = hostTestMessage('survive subscriber failure') - - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - - expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Failed subscriber transport must not block provider delivery or healthy subscribers. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - await vi.waitFor(() => - expect( - events.some( - (event) => - event.type === 'batch' && - event.batch.submissions?.some((entry) => entry.dispatchState === 'accepted') - ) - ).toBe(true) - ) - }) - - it('resets a subscriber whose epoch is gone', async () => { - await attach() - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-3', - sessionId: SESSION, - emit: (event) => events.push(event), - cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } - }) - expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) - }) - - it('publishes the replacement fence when the owner generation changes', async () => { - const record = await attach() - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-4', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - const released = await store.evictProvenDeadOwner({ - sessionId: SESSION, - expectedFence: record?.lease.runtimeFence ?? 1, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - - const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) - if (!replacement.ok) { - throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) - } - expect(events.findLast((event) => event.type === 'snapshot')?.fence).toBe(replacement.fence) - expect(events.at(-1)).toMatchObject({ fence: replacement.fence }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-listed-stop-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-listed-stop-lifetime.test.ts new file mode 100644 index 000000000000..0fd3aa98c863 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-listed-stop-lifetime.test.ts @@ -0,0 +1,237 @@ +// A Stop of the listed background helpers acts only on the lifetimes it captured: a helper that +// restarts under the same id, or appears refused while the Stop runs, is not this Stop's to end. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { AgentHookServer } from '../../agent-hooks/server' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { runTargetedCancel } from './structured-agent-session-targeted-cancel' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +describe('a listed background Stop', () => { + it('omits an earlier listed Stop when its same-ID lifetime restarts during acceptance', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-background-stop-lifetime-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const owner = new StructuredAgentSessionStatusOwnership(() => undefined) + const live = (restart = false): AgentChildWorkEvidence => ({ + type: 'live', + observedAt: 1, + child: { + handle: { idKind: 'task_id', id: 'helper', runId: 'spawn-a' }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + }, + ...(restart ? { restart: true as const } : {}) + }) + owner.publishChildWork('session-1', [live()], 'claude') + let acceptStop: (() => void) | undefined + const accepting = new Promise((resolve) => { + acceptStop = resolve + }) + const stopBackgroundTasks = vi.fn( + async (request: { acknowledge: (value: { taskId: string }) => void }) => { + request.acknowledge({ taskId: 'helper' }) + return { cancelled: true } + } + ) + const ctx = acceptedStop({ + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This path calls only stopBackgroundTasks on the test adapter. + adapter: { stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + }) + ctx.operationReceipt!.commitAlone = () => accepting + const stopping = runTargetedCancel(ctx, { + clientOperationId: 'stop-earlier-lifetime', + scope: 'background-tasks', + childWork: () => [ + liveTask('helper', { invocation: { invocationId: 'spawn-a', generation: 1 } }) + ], + captureStops: (targets) => + owner.captureChildWorkStops( + 'session-1', + targets.map((target) => target.providerId) + ), + acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude') + }) + expect(stopBackgroundTasks).not.toHaveBeenCalled() + owner.publishChildWork('session-1', [live(true)], 'claude') + acceptStop?.() + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(stopBackgroundTasks).not.toHaveBeenCalled() + expect(owner.childWorkOpen('session-1')).toBe(true) + }) + + it('does not close the agent when a refused helper appears during a listed Stop all', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-background-stop-refused-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const server = new AgentHookServer() + let refuse = false + const owner = new StructuredAgentSessionStatusOwnership(() => ({ + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => { + if (!refuse) { + server.ingestStructuredChildWork(subject, evidence, provider) + } + }, + readChildWork: (subject) => server.getStructuredChildWorkViews(subject), + readChildWorkHandles: (subject) => server.getStructuredChildWorkHandles(subject) + })) + const location: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } + const summary: AgentSessionStatusSummary = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'idle', + latestPrompt: 'fixture', + updatedAt: 1 + } + const live = (runId: string): AgentChildWorkEvidence => ({ + type: 'live', + observedAt: 1, + child: { + handle: { idKind: 'task_id', id: 'helper', runId }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + }) + owner.publish(summary, location) + owner.publishChildWork('session-1', [live('spawn-a')], 'claude') + expect(owner.readChildWork('session-1')).toHaveLength(1) + expect(owner.childWorkIncomplete('session-1')).toBe(false) + + let releaseStop: (() => void) | undefined + const waiting = new Promise((resolve) => { + releaseStop = resolve + }) + let stopStarted: (() => void) | undefined + const started = new Promise((resolve) => { + stopStarted = resolve + }) + const stopBackgroundTasks = vi.fn( + async (request: { acknowledge: (value: { taskId: string }) => void }) => { + stopStarted?.() + await waiting + request.acknowledge({ taskId: 'helper' }) + return { cancelled: true } + } + ) + const stopAgent = vi.fn(async () => {}) + const ctx = acceptedStop({ + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'claude', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This path calls only stopBackgroundTasks on the test adapter. + adapter: { stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + }) + const stopping = runTargetedCancel(ctx, { + clientOperationId: 'stop-all-before-refusal', + scope: 'background-tasks', + childWork: () => owner.readChildWork('session-1'), + captureStops: (targets) => + owner.captureChildWorkStops( + 'session-1', + targets.map((target) => target.providerId) + ), + acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude'), + unlisted: { present: () => owner.childWorkIncomplete('session-1'), stop: stopAgent } + }) + await started + refuse = true + owner.publishChildWork('session-1', [live('spawn-b')], 'claude') + refuse = false + releaseStop?.() + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(owner.childWorkIncomplete('session-1')).toBe(true) + expect(owner.childWorkOpen('session-1')).toBe(true) + expect(stopAgent).not.toHaveBeenCalled() + }) +}) + +/** A Stop whose receipt is already saved, as the receipt runner hands a background Stop. */ +function acceptedStop(ctx: AgentSessionTurnContext): AgentSessionTurnContext { + return { + ...ctx, + operationReceipt: { + write: () => {}, + committed: () => {}, + isCommitted: () => true, + commitAlone: async () => {} + } + } +} + +/** A live child record the strip would offer a stop, named by the provider as `providerId`. */ +function liveTask( + providerId: string, + overrides: Partial = {} +): AgentChildWorkView { + return { + id: `child-${providerId}`, + providerId, + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: `spawn-${providerId}`, generation: 1 }, + ...overrides + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index a75ffffe3be0..4e4587b2c189 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -448,4 +448,36 @@ describe("a card's own Cancel, as its provider answers it", () => { }) expect(routes.stop).not.toHaveBeenCalled() }) + + it('leaves no Stop request row when the provider declines a live card Cancel', async () => { + const { journal, itemId } = await pendingPrompt(undefined, true) + await expect( + performCancel( + context(journal, async () => ({ cancelled: false })), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) + expect(journal.snapshot().items.filter((item) => item.body.kind === 'status')).toEqual([]) + }) + + it('leaves no Stop request row when a live card Cancel throws', async () => { + const { journal, itemId } = await pendingPrompt(undefined, true) + await expect( + performCancel( + context(journal, async () => { + throw new Error('lost') + }), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('lost') + expect(journal.snapshot().items.filter((item) => item.body.kind === 'status')).toEqual([]) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts deleted file mode 100644 index 10359318d4bf..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ /dev/null @@ -1,564 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause -} from '../../../shared/agent-session-wire' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - projectQueuedMessageCards, - queuedMessageCardSteers, - queuedMessagesQueuePause -} from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - readQueuePublication, - structuredQueueSendGate -} from './structured-agent-session-queued-publication' -import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - HOST_TEST_SESSION, - hostTestMessage, - hostTestOperationId -} from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - QUEUED_RIG_CALLER, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' -import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' - -let rig: QueuedMessageTestRig - -const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } - -beforeEach(async () => { - vi.useFakeTimers() - rig = await createQueuedMessageTestRig({ restartable: true }) -}) - -afterEach(async () => { - try { - await rig.dispose() - } finally { - vi.useRealTimers() - } -}) - -async function queuedDraft(text: string): Promise { - const queued = await rig.send(text, 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error('expected a queued receipt') - } - return queued.value.queued.messageId -} - -async function published(): Promise<{ - queuePause: AgentSessionQueuePause | null - nextQueuedMessageId: string | null -}> { - const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - if (!page.ok) { - throw new Error('history refused') - } - return { - queuePause: page.page.queuePause ?? null, - nextQueuedMessageId: page.page.nextQueuedMessageId ?? null - } -} - -/** Include the hidden restart pause. */ -function derivedPauses(): string[] { - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - return structuredQueuePauses(journal).map((pause) => pause.reason) -} - -async function expectNothingSent(...draftIds: string[]): Promise { - await vi.advanceTimersByTimeAsync(250) - for (const draftId of draftIds) { - expect(await rig.handoff(draftId)).toBeUndefined() - } -} - -type ClientView = { - working: boolean - stopLive: boolean - header: boolean - steers: boolean[] - /** Card hold reason selects its caption; turn means ordinary waiting. */ - holds: string[] - cards: number - nextQueuedMessageId: string | null -} - -function composerSend(text: string): ReturnType { - return rig.send(text, 'queue-if-active') -} - -/** Fold publications individually to catch transient client states. */ -async function watchClient(): Promise { - const views: ClientView[] = [] - const submissions = new Map() - const turns = new Map() - let queued: AgentSessionQueuedMessage[] = [] - let queuePause: AgentSessionQueuePause | null = null - let nextQueuedMessageId: string | null = null - await rig.host.subscribe({ - id: 'client-view', - sessionId: HOST_TEST_SESSION, - emit: (event) => { - if (event.type === 'end') { - return - } - const rows = event.type === 'batch' ? event.batch : event.page - for (const submission of rows.submissions) { - submissions.set(submission.clientMessageId, submission) - } - for (const item of rows.items) { - if (item.body.kind === 'turn') { - turns.set(item.itemId, item.body.state) - } - } - if (event.queuedMessages !== undefined) { - queued = event.queuedMessages ?? [] - queuePause = event.queuePause ?? null - nextQueuedMessageId = event.nextQueuedMessageId ?? null - } - const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null - const all = [...submissions.values()] - const hostWorking = isStructuredAgentSessionMainAgentWorking(running, all) - // Match use-structured-agent-session.ts projection. - const working = hostWorking || (nextQueuedMessageId !== null && !hostWorking) - const cards = projectQueuedMessageCards(queued, all, { queuePaused: queuePause !== null }) - // Match use-structured-agent-session-queued-messages.ts projection. - const header = queuedMessagesQueuePause(cards, queuePause) !== null - views.push({ - working, - stopLive: hostWorking, - header, - steers: cards.map((card) => queuedMessageCardSteers(card)), - holds: cards.map((card) => card.hold), - cards: cards.length, - nextQueuedMessageId - }) - } - }) - return views -} - -function expectOneWorkingRun(views: readonly ClientView[]): void { - expect(views.filter((view) => !view.working)).toEqual([]) - expect(views.flatMap((view) => view.steers)).not.toContain(false) - // Include the gap between settlement/Resume and the next handover. - expect(views.some((view) => !view.stopLive && view.nextQueuedMessageId !== null)).toBe(true) -} - -describe('a Stop holds the queue, in order', () => { - it('a card typed while the Stop lands waits behind the held one; Resume sends both in order, never reading idle between them', async () => { - const working = await rig.workingSend() - const held = await queuedDraft('held by the stop') - await rig.stop() - const typed = await queuedDraft('typed while the stop lands') - const views = await watchClient() - expect(views.at(-1)).toMatchObject({ working: true, header: true }) - await rig.settleAccepted(working, 'stopped') - await eventually(() => expect(views.at(-1)).toMatchObject({ working: false, header: true })) - await expectNothingSent(held, typed) - expect((await published()).queuePause).toEqual({ reason: 'stopped' }) - const resumedAt = views.length - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - expect(await rig.handoff(typed)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(held), 'held') - await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - expectOneWorkingRun(views.slice(resumedAt)) - }) - - it('after Resume over held cards the chat goes from idle and paused straight to working with Stop', async () => { - const working = await rig.workingSend() - const first = await queuedDraft('first') - await queuedDraft('second') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const views = await watchClient() - const before = views.length - expect(views.at(-1)).toMatchObject({ working: false, header: true }) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - await eventually(() => expect(views.at(-1)?.stopLive).toBe(true)) - expectOneWorkingRun(views.slice(before)) - }) -}) - -describe('after a restart, nothing sends by itself', () => { - async function restartedWithCards(): Promise<{ first: string; second: string }> { - await rig.workingSend() - const first = await queuedDraft('A') - const second = await queuedDraft('B') - rig.crashRestartHostProcess() - await published() - return { first, second } - } - - /** Restart after settling the old child so a later turn can reach the provider. */ - async function restartedIdleWithCards(): Promise<{ first: string; second: string }> { - const working = await rig.workingSend() - const first = await queuedDraft('A') - const second = await queuedDraft('B') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'before-restart') - await published() - return { first, second } - } - - it('idle: no card is handed off across opens and commits, and no pause or next card is published', async () => { - const { first, second } = await restartedWithCards() - const views = await watchClient() - await expectNothingSent(first, second) - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - expect(derivedPauses()).toEqual(['restarted']) - await rig.host.close(HOST_TEST_SESSION, 'evict') - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - await expectNothingSent(first, second) - expect(views.at(-1)).toMatchObject({ - working: false, - header: false, - cards: 2, - holds: ['turn', 'turn'], - steers: [true, true] - }) - expect(await rig.drafts()).toEqual([ - { messageId: first, state: 'waiting' }, - { messageId: second, state: 'waiting' } - ]) - }) - - it("Resume: Orca's carry-on turn runs first, then A, then B, as if no restart happened", async () => { - const { first, second } = await restartedIdleWithCards() - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - const views = await watchClient() - const carryOn = rig.send('continue where you left off') - expect(await carryOn.result).toMatchObject({ - ok: true, - value: { submission: expect.anything() } - }) - await eventually(async () => - expect((await rig.submission(carryOn.id))?.handedOverAt).toBeDefined() - ) - await expectNothingSent(first, second) - await rig.settleAccepted(carryOn.id, 'carry-on') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(await rig.handoff(second)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(first), 'A') - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) - expect(views.filter((view) => view.header)).toEqual([]) - expectOneWorkingRun(views.slice(views.findIndex((view) => view.working))) - }) - - it("the person's own message goes straight to the agent first, then A, then B", async () => { - const { first, second } = await restartedIdleWithCards() - const views = await watchClient() - const message = composerSend('a new instruction') - expect(await message.result).toMatchObject({ - ok: true, - value: { submission: expect.anything() } - }) - await eventually(async () => - expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() - ) - await expectNothingSent(first, second) - await rig.settleAccepted(message.id, 'message') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(await rig.handoff(second)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(first), 'A') - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) - expect(views.filter((view) => view.header)).toEqual([]) - }) - - it('Steer on a card sends it now, and the rest follow it in order', async () => { - const { first, second } = await restartedIdleWithCards() - expect(await rig.sendNow(second)).toMatchObject({ - ok: true, - value: { submission: { queuedMessageId: second } } - }) - await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) - await expectNothingSent(first) - await rig.settleAccepted(await rig.handoffId(second), 'B') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - }) - - it("a Stop's row from before the restart is not shown either; the carry-on releases every card, in order", async () => { - const working = await rig.workingSend() - const first = await queuedDraft('A') - const second = await queuedDraft('B') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - expect((await published()).queuePause).toEqual({ reason: 'stopped' }) - await rig.restartHostProcess() - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - expect(derivedPauses()).toEqual(['stopped', 'restarted']) - const views = await watchClient() - expect(views.at(-1)).toMatchObject({ working: false, header: false }) - const carryOn = rig.send('continue where you left off') - await carryOn.result - await expectNothingSent(first, second) - await rig.settleAccepted(carryOn.id, 'carry-on') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(await rig.handoff(second)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(first), 'A') - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) - expect(views.filter((view) => view.header)).toEqual([]) - }) - - it('a card written after the restart waits while any card from before it waits; then it is an ordinary queue', async () => { - const { first, second } = await restartedIdleWithCards() - const message = rig.send('a new instruction') - await message.result - await eventually(async () => - expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() - ) - const typed = await queuedDraft('typed while it waits') - await rig.settleRejected(message.id, 'turn/start refused') - await expectNothingSent(first, second, typed) - expect(derivedPauses()).toEqual(['restarted']) - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) - await expectNothingSent(second, typed) - expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) - await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - }) - - it('a hand-off the quit cut short goes back to waiting first, and nothing sends', async () => { - rig.dispose() - rig = await createQueuedMessageTestRig({ restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) - const working = await rig.workingSend() - const first = await queuedDraft('A') - const second = await queuedDraft('B') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - // Resume must start a new child after idle retirement. - await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() - // Hold spawn so the recorded handoff cannot reach the provider. - const release = rig.holdNextStart() - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - const cutShort = await rig.handoffId(first) - expect((await rig.handoff(first))?.handedOverAt).toBeUndefined() - rig.crashRestartHostProcess() - release() - await published() - await eventually(async () => - expect(await rig.drafts()).toEqual([ - { messageId: first, state: 'waiting' }, - { messageId: second, state: 'waiting' } - ]) - ) - await expectNothingSent() - expect(await rig.handoffId(first)).toBe(cutShort) - expect(await rig.handoff(second)).toBeUndefined() - expect(derivedPauses()).toEqual(['restarted']) - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - }) - - it('an epoch replacement (a rewind or a legacy import) before any turn still holds the cards', async () => { - // A prior turn makes the replacement epoch restate the lift. - await rig.settleAccepted(await rig.workingSend(), 'earlier') - const { first, second } = await restartedWithCards() - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - await journal.replaceEpochItems( - 'legacy_import', - structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION), - [] - ) - await expectNothingSent(first, second) - expect(derivedPauses()).toEqual(['restarted']) - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - }) -}) - -describe('a message sent over a held queue', () => { - async function heldIdleQueue() { - const working = await rig.workingSend() - const first = await queuedDraft('first') - const second = await queuedDraft('second') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const views = await watchClient() - expect(views.at(-1)).toMatchObject({ working: false, header: true, cards: 2 }) - return { first, second, views } - } - - it('goes out at once: the paused row goes as it is sent and never comes back, and the held cards follow it in order', async () => { - const { first, second, views } = await heldIdleQueue() - const message = composerSend('a new instruction') - const sentAt = views.length - expect(await message.result).toMatchObject({ - ok: true, - value: { submission: expect.anything() } - }) - // Hide the pause while its lifting turn is in flight; cards stay held until provider acceptance. - expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - expect(await rig.handoff(first)).toBeUndefined() - await eventually(async () => - expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() - ) - await eventually(() => expect(views.at(-1)?.stopLive).toBe(true)) - const acceptedAt = views.length - await rig.settleAccepted(message.id, 'message') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(await rig.handoff(second)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(first), 'first') - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) - const waiting = views.slice(sentAt, acceptedAt) - expect(waiting.some((view) => view.stopLive && view.cards === 2)).toBe(true) - expect(views.slice(sentAt).filter((view) => view.header)).toEqual([]) - expect(views.flatMap((view) => view.steers)).not.toContain(false) - }) - - it('Steer on a held card: the paused row goes from the press until its turn is accepted, then the rest follow', async () => { - const { first, second, views } = await heldIdleQueue() - const pressedAt = views.length - expect(await rig.sendNow(second)).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) - expect((await published()).queuePause).toBeNull() - await rig.settleAccepted(await rig.handoffId(second), 'second') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(views.slice(pressedAt).filter((view) => view.header)).toEqual([]) - }) - - it('a Steer the agent refuses lifts nothing: the paused row comes back over the card before it', async () => { - const { first, second, views } = await heldIdleQueue() - expect(await rig.sendNow(second)).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) - await eventually(() => expect(views.at(-1)?.header).toBe(false)) - await rig.settleRejected(await rig.handoffId(second), 'turn/start refused') - await eventually(() => expect(views.at(-1)?.header).toBe(true)) - expect((await published()).queuePause).toEqual({ reason: 'stopped' }) - expect(await rig.handoff(first)).toBeUndefined() - }) - - it('a send the agent refuses lifts nothing: the paused row comes back over the held cards', async () => { - const { first, views } = await heldIdleQueue() - const message = composerSend('refused') - await message.result - await eventually(async () => - expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() - ) - await eventually(() => expect(views.at(-1)?.header).toBe(false)) - await rig.settleRejected(message.id, 'turn/start refused') - await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, cards: 2 })) - expect(await rig.handoff(first)).toBeUndefined() - }) -}) - -describe("the queue's next card on a history page", () => { - it('names the card a Resume released while the drain still waits for the session', async () => { - const working = await rig.workingSend() - const card = await queuedDraft('held, then released') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - // Commit Resume while holding its remaining work and the drain behind it. - let release: () => void = () => undefined - const held = new Promise((resolve) => (release = resolve)) - const write = AgentSessionJournal.prototype.appendQueueResume - const resume = vi - .spyOn(AgentSessionJournal.prototype, 'appendQueueResume') - .mockImplementationOnce(async function (this: AgentSessionJournal, fence: number) { - const cursor = await write.call(this, fence) - await held - return cursor - }) - const resumed = rig.resume() - try { - await eventually(async () => { - const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(page.ok && page.page.nextQueuedMessageId).toBe(card) - }) - } finally { - release() - resume.mockRestore() - } - expect(await resumed).toMatchObject({ ok: true, value: { resumed: true } }) - }) -}) - -describe('where the host would refuse the send', () => { - async function cardAddedAfterClear() { - await rig.settleAccepted(await rig.workingSend(), 'a') - const fields = { command: 'clear' as const } - const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) - expect(cleared).toMatchObject({ ok: true }) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - // Hold delivery after clear while reading the gate; install the barrier before queuing the task. - let release = (): void => undefined - const gate = new Promise((resolve) => (release = resolve)) - const held = rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, () => gate) - const card = 'left-behind' - await journal.queuedMessages.insert({ - messageId: card, - body: hostTestMessage('queued after clear') - }) - const record = rig.store.getRecord(HOST_TEST_SESSION) - if (!record) { - throw new Error('expected the record') - } - const done = async (): Promise => { - release() - await held - } - return { card, journal, record, fence: record.lease.runtimeFence, done } - } - - it('a card queued after clear is sendable with the completed clear record still present', async () => { - const { card, journal, record, fence, done } = await cardAddedAfterClear() - const gate = structuredQueueSendGate( - { store: rig.store, sessions: rig.host.collaboratorsForTests().sessions }, - HOST_TEST_SESSION - ) - expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBe(card) - const { conversationCommand: _cleared, ...unblocked } = record - const next = readQueuePublication(journal, () => ({ - record: unblocked, - work: new StructuredAgentSessionCurrentWork(journal, fence) - })) - expect(next.nextQueuedMessageId).toBe(card) - await done() - await vi.waitFor(() => expect(journal.queuedMessages.get(card)?.state).toBe('dispatched')) - }) - - it('a rewind whose outcome is unknown names no next card', async () => { - const { journal, record, fence, done } = await cardAddedAfterClear() - const { conversationCommand: _cleared, ...unblocked } = record - const rewind = { - operationId: hostTestOperationId(), - callerKey: QUEUED_RIG_CALLER.callerKey, - itemId: 'item-1', - expectedEpoch: 'epoch-1', - phase: 'prepared' as const, - retained: [] - } - const next = (gateRecord: typeof record) => - readQueuePublication(journal, () => ({ - record: gateRecord, - work: new StructuredAgentSessionCurrentWork(journal, fence) - })).nextQueuedMessageId - expect(next({ ...unblocked, rewind })).toBeNull() - expect(next({ ...unblocked, rewind: { ...rewind, phase: 'provider-succeeded' } })).toBeNull() - expect(next({ ...unblocked, rewind: { ...rewind, phase: 'completed' } })).not.toBeNull() - await done() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index 9e69b0dcb8de..ec7e0c3d0036 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -1,12 +1,12 @@ import { vi, type Mock } from 'vitest' -import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { activeProviderContext } from '../../../shared/agent-session-provider-context' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_THREAD as THREAD @@ -17,15 +17,18 @@ export type QueuedRigProviderOptions = { recoverRewind?: NonNullable /** Resume the saved thread on later children after close/death. */ restartable?: true - starting?: true - /** Stop ends the child, as with Claude. */ - stopEndsSession?: true /** Startup never answers; queued input cannot run. */ - startUnanswered?: true + starting?: true + /** Wait for stopped work to end or grace expiry before closing the child. */ + windsDown?: true + /** The model its child reports running; otherwise the agent's own `default`. */ reportsModel?: string reportsEffort?: string } +/** How long a winding-down child is given after the interrupt before the Stop ends it. */ +export const QUEUED_RIG_STOP_GRACE_MS = 2_000 + export function createQueuedRigProvider( store: Pick, options: QueuedRigProviderOptions, @@ -89,7 +92,6 @@ export function createQueuedRigProvider( spawnToken }, acquisitionGeneration: 'generation-1', - ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, handle: codexProviderHandle(thread), @@ -104,14 +106,25 @@ export function createQueuedRigProvider( releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, - ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), - ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async ({ commit }) => commit()), setOption, ...(options.rewind ? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) } : {}), - ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}) + ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}), + ...(options.windsDown + ? { + awaitStoppedRequestEnd: async (sessionId: string, stoppedAt: number) => { + while (Date.now() < stoppedAt + QUEUED_RIG_STOP_GRACE_MS && working(sessionId)) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + } + } + : {}) + } + + function working(sessionId: string): boolean { + return host().currentWork(sessionId)?.working() ?? false } const adapter = options.starting ? scripted : startsWhenPublished(scripted, host) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 62f8b0e714f1..cf722b02911f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -29,7 +29,7 @@ afterEach(() => observationClock.restore()) function installRigLifecycleHooks(): void { beforeEach(async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) }) afterEach(() => observationClock.restoreBeforeCleanup(() => rig.dispose())) @@ -68,6 +68,10 @@ async function stoppedDraft(): Promise { return draftId } +function laneDrained(): Promise { + return rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) +} + async function handedOver(draftId: string): Promise { await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) } @@ -120,12 +124,15 @@ describe("a Stop's queue pause", () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { - const working = await rig.workingSend() + it("a draft typed after the Stop waits with the rest: the pause is the queue's", async () => { + await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() - const typedId = await queuedDraft('typed while stopping') - await rig.settleAccepted(working, 'stopped') + await laneDrained() + + const refused = await handedOverUserSend('sent after the stop') + const typedId = await queuedDraft('typed while that send waits') + await rig.settleRejected(refused, 'turn/start refused') await expectPaused(olderId, typedId) expect(await rig.drafts()).toEqual([ { messageId: olderId, state: 'waiting' }, @@ -224,12 +231,14 @@ describe('the pause read', () => { describe('a card queued after a Stop is a new instruction', () => { installRigLifecycleHooks() - it('a correction typed after a Stop over an empty queue sends when the stopped turn ends', async () => { - const working = await rig.workingSend() + it('a correction typed after a Stop over an empty queue goes straight to the next agent', async () => { + await rig.workingSend() await rig.stop() - const correction = await queuedDraft('typed right after the stop') - await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + + const correction = await rig.send('typed right after the stop', 'queue-if-active').result + expect(correction).toMatchObject({ ok: true, value: { submission: { fence: 2 } } }) + expect(await rig.drafts()).toEqual([]) + expect(await rig.queuePause()).toBeNull() }) it('a card sent now before the Stop and taken anyway lifts nothing, and holds nothing typed later', async () => { @@ -430,19 +439,29 @@ describe('Resume', () => { }) it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { - const working = await rig.workingSend() + await rig.workingSend() const draftId = await queuedDraft('paused twice') await rig.stop() const operationId = hostTestOperationId() + const { held, release } = holdDelivery() expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) - await rig.stop() + await held + + const stopping = rig.stop() + release() + await stopping + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + ) + const withdrawn = await rig.handoffId(draftId) expect(await rig.resume(operationId)).toMatchObject({ ok: true, replayed: true, value: { resumed: false } }) - await rig.settleAccepted(working, 'stopped') - await expectPaused(draftId) + await observationClock.observe(250) + expect(await rig.handoffId(draftId)).toBe(withdrawn) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) it("of a Stop from before a restart, which no client offers there, also lifts the restart's pause", async () => { @@ -485,8 +504,8 @@ describe('a failed Stop', () => { expect(await rig.queuePause()).toBeNull() }) - it('keeps its pause when it fails after the interrupt reached the agent', async () => { - await rig.workingSend() + it('keeps its pause when its note fails after the interrupt reached the agent', async () => { + await openRigTurnFor(rig, await rig.workingSend()) const draftId = await queuedDraft('paused by stop') const append = AgentSessionJournal.prototype.appendItem const failing = vi diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 405c26a1a521..592d4100aace 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,21 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import Database from '../../sqlite/sync-database' -import { journalDatabasePath } from '../agent-session-journal/journal-host-database' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalAnsweredTurnIdentity } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' -import { - HOST_TEST_SESSION, - HOST_TEST_THREAD, - hostTestMessage, - hostTestOperationId -} from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, HOST_TEST_THREAD } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, eventually, - QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' @@ -60,13 +52,6 @@ async function expectHeld(reason: string | null, ...draftIds: string[]): Promise expect(await rig.queuePause()).toEqual(reason === null ? null : { reason }) } -async function mailTurn(): Promise { - const mail = rig.send('coordinator mail') - await mail.result - await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) - return mail.id -} - function withdraw(clientMessageId: string, answeredInTurn?: AgentJournalAnsweredTurnIdentity) { return rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, @@ -91,267 +76,57 @@ async function drawn(clientMessageId: string) { return message ? { stoppedBeforeStart: message.stoppedBeforeStart } : null } -function pauseTables(): number { - const db = new Database(journalDatabasePath(rig.root), { readonly: true }) - try { - return db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") - .all().length - } finally { - db.close() - } -} - -describe("Stop's event", () => { - it('is written before the interrupt reaches the agent', async () => { - await rig.workingSend() - let pausedAtInterrupt: unknown = 'not interrupted' - rig.cancelTurn.mockImplementationOnce(async () => { - pausedAtInterrupt = structuredQueuePauses(journal())[0]?.reason ?? null - return { cancelled: true } - }) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(pausedAtInterrupt).toBe('stopped') - }) - - it('over an EMPTY queue holds nothing: a card queued during a later mail turn sends normally', async () => { - const working = await rig.workingSend() - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const mail = await mailTurn() - const typed = await queuedDraft('typed during the mail turn') - expect(await rig.queuePause()).toBeNull() - await rig.settleAccepted(mail, 'mail') - await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - }) - - it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { - const working = await rig.workingSend() - const held = await queuedDraft('queued before the stop') - await rig.stop() - const later = await queuedDraft('typed while the stopped turn winds down') - await rig.settleAccepted(working, 'stopped') - await expectHeld('stopped', held, later) - expect(await rig.drafts()).toEqual([ - { messageId: held, state: 'waiting' }, - { messageId: later, state: 'waiting' } - ]) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - expect(await rig.handoff(later)).toBeUndefined() - await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(held), 'held') - await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) - }) - - it("any accepted turn sent after it lifts it, Orca's own mail included; a later Stop supersedes", async () => { - const working = await rig.workingSend() - const first = await queuedDraft('first') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - await expectHeld('stopped', first) - const person = rig.send('the person asks for a turn') - await person.result - // A later Stop cannot be lifted by an earlier send. - await rig.stop() - await rig.settleAccepted(person.id, 'person') - await expectHeld('stopped', first) - const mail = await mailTurn() - await expectHeld(null, first) - await rig.settleAccepted(mail, 'mail') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - }) - - it('a card handed over and stopped before the agent confirms it reads as sent, never sent again; only the cards behind it send', async () => { - const working = await rig.workingSend() - await openRigTurnFor(rig, working) - const steered = await queuedDraft('sent now into the turn') - const opening = await queuedDraft('opens its own turn') - const last = await queuedDraft('waiting behind them') - await rig.sendNow(steered) - await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) - const steer = await rig.handoffId(steered) - // Codex may have received the steer despite ending its turn; no non-delivery proof exists. - await rig.stop() - await withdraw(steer, { - turn: { - provider: 'codex', - threadId: HOST_TEST_THREAD, - turnId: `turn-of-${working}`, - ordinal: 0 - }, - via: 'steer' - }) - await rig.settleAccepted(working, 'stopped') - await openRigTurnFor(rig, working, 'interrupted') - await expectHeld('stopped', opening, last) - expect(await rig.drafts()).toEqual([ - { messageId: opening, state: 'waiting' }, - { messageId: last, state: 'waiting' } - ]) - expect(await send(steer)).toMatchObject({ dispatchState: 'unknown', recovered: true }) - expect(await drawn(steer)).toEqual({ stoppedBeforeStart: undefined }) - - // Stop after handover but before turn-open leaves delivery unconfirmed. - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect((await rig.handoff(opening))?.handedOverAt).toBeDefined()) - const opener = await rig.handoffId(opening) - await rig.stop() - await withdraw(opener) - await expectHeld('stopped', last) - expect(await rig.drafts()).toEqual([{ messageId: last, state: 'waiting' }]) - expect(await send(opener)).toMatchObject({ dispatchState: 'rejected' }) - expect(await drawn(opener)).toEqual({ stoppedBeforeStart: true }) - - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(last)).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(last), 'last') - expect([await rig.handoffId(steered), (await send(opener))?.dispatchState]).toEqual([ - steer, - 'rejected' - ]) - await rig.restartHostProcess() - await rig.queuePause() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).not.toContain('restarted') - }) - - it('survives a host crash, unshown: the next open marks the hand-off unknown, nothing sends, Resume releases', async () => { - await openRigTurnFor(rig, await rig.workingSend()) - const sentId = await queuedDraft('sent now into the turn') - const waiting = await queuedDraft('waiting behind it') - await rig.sendNow(sentId) - await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) - await rig.stop() - rig.crashRestartHostProcess() - expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') - await expectHeld(null, waiting) - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ - 'stopped', - 'restarted' - ]) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined()) - }) -}) - -describe("a restart's hold over a card queued after a Stop", () => { - it('a card queued after a Stop over an empty queue, before a restart, waits unshown', async () => { - const working = await rig.workingSend() - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const mail = await mailTurn() - const typed = await queuedDraft('typed during the mail turn') - rig.crashRestartHostProcess() - await rig.settleAccepted(mail, 'mail') - // Post-Stop cards are held only by restart, whose pause is unpublished. - await rig.queuePause() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ - 'stopped', - 'restarted' - ]) - await expectHeld(null, typed) - }) - - it("a card queued during the queue's own send after a Stop, before a restart, waits unshown", async () => { - const working = await rig.workingSend() - await rig.stop() - const correction = await queuedDraft('typed while the interrupt lands') - await rig.settleAccepted(working, 'stopped') - await eventually(async () => - expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() - ) - const typed = await queuedDraft('typed during that send') - rig.crashRestartHostProcess() - await rig.settleAccepted(await rig.handoffId(correction), 'correction') - await rig.queuePause() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) - await expectHeld(null, typed) - }) -}) - -describe("a /clear's carried cards", () => { - function clear() { - const fields = { command: 'clear' as const } - return rig.host.conversationCommand(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) - } - - it("wait 'cleared' on the replacement until a turn there", async () => { - const working = await rig.workingSend() - const carried = await queuedDraft('written for the old context') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const cleared = await clear() - const replacementId = cleared.ok ? HOST_TEST_SESSION : undefined - if (!replacementId) { - throw new Error(`expected clear to succeed: ${JSON.stringify(cleared)}`) - } - expect(await rig.queuePause(replacementId)).toBeNull() - expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([ - 'stopped', - 'cleared' - ]) - const text = hostTestMessage('hi') - const person = rig.host.send(QUEUED_RIG_CALLER, { - envelope: rig.envelope( - { body: text }, - 'agentSession.send', - hostTestOperationId(), - replacementId - ), - body: text - }) - const sent = await person - if (!sent.ok || !('submission' in sent.value)) { - throw new Error(`expected an immediate send: ${JSON.stringify(sent)}`) - } - await eventually(async () => - expect( - (await rig.host.journalSnapshot(replacementId)).submissions.find( - (entry) => entry.clientMessageId === sent.value.clientMessageId - )?.handedOverAt - ).toBeDefined() - ) - await rig.host.settleLateDispatch({ - sessionId: replacementId, - clientMessageId: sent.value.clientMessageId, - providerIdentity: { - provider: 'codex', - threadId: rig.store.getRecord(replacementId)!.providerHandleChain.at(-1)!.handle.nativeId, - turnId: 'turn-hi', - ordinal: 0 - } - }) - expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried) - expect(structuredQueuePauses(journal(replacementId))).toEqual([]) - }) -}) - -describe('no stored pause', () => { - it('is read or written across Stop, Resume and /clear', async () => { - const working = await rig.workingSend() - const draftId = await queuedDraft('paused') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - await expectHeld('stopped', draftId) - expect(pauseTables()).toBe(0) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - expect(pauseTables()).toBe(0) - await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) - await queuedDraft('carried') - await rig.stop() - await rig.settleAccepted(await rig.handoffId(draftId), 'drained') - const fields = { command: 'clear' as const } - const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) - const replacementId = cleared.ok ? HOST_TEST_SESSION : undefined - expect( - replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason) - ).toEqual(['stopped', 'cleared']) - expect(pauseTables()).toBe(0) +it('a card handed over and stopped before the agent confirms it reads as sent, never sent again; only the cards behind it send', async () => { + const working = await rig.workingSend() + await openRigTurnFor(rig, working) + const steered = await queuedDraft('sent now into the turn') + const opening = await queuedDraft('opens its own turn') + const last = await queuedDraft('waiting behind them') + await rig.sendNow(steered) + await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) + const steer = await rig.handoffId(steered) + + await rig.stop() + await withdraw(steer, { + turn: { + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: `turn-of-${working}`, + ordinal: 0 + }, + via: 'steer' }) + await rig.settleAccepted(working, 'stopped') + await openRigTurnFor(rig, working, 'interrupted') + await expectHeld('stopped', opening, last) + expect(await rig.drafts()).toEqual([ + { messageId: opening, state: 'waiting' }, + { messageId: last, state: 'waiting' } + ]) + + expect(await send(steer)).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await drawn(steer)).toEqual({ stoppedBeforeStart: undefined }) + + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect((await rig.handoff(opening))?.handedOverAt).toBeDefined()) + const opener = await rig.handoffId(opening) + await rig.stop() + await withdraw(opener) + await expectHeld('stopped', last) + expect(await rig.drafts()).toEqual([{ messageId: last, state: 'waiting' }]) + + expect(await send(opener)).toMatchObject({ dispatchState: 'rejected' }) + expect(await drawn(opener)).toEqual({ stoppedBeforeStart: true }) + + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(last)).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(last), 'last') + expect([await rig.handoffId(steered), (await send(opener))?.dispatchState]).toEqual([ + steer, + 'rejected' + ]) + + await rig.restartHostProcess() + await rig.queuePause() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).not.toContain('restarted') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 653a07df1637..8804e53265a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -15,7 +15,7 @@ const observationClock = createStructuredSessionObservationClock(() => rig.host, beforeEach(() => observationClock.start()) beforeEach(async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index 012081ac321e..de4a01b46487 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -181,7 +181,7 @@ it.each(['append', 'wait'] as const)( expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) expect(changeThreadGoal).not.toHaveBeenCalled() if (phase === 'append') { - expect(append.mock.calls.some(([, body]) => body.kind === 'status')).toBe(true) + expect(append.mock.calls.some(([, body]) => body.kind === 'status')).toBe(false) } } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index 8f691c90fef4..3abfa481a7be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { AgentJournalTurnLifecycleState } from '../../../shared/agent-session-journal-types' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { @@ -77,13 +79,19 @@ describe('a Stop pressed again', () => { expect(await statusRows()).toEqual([REQUESTED]) }) - it('naming no turn, rewrites the row of the turn running, as a Stop naming it does', async () => { + it('naming no turn, keys its row by the turn running, as a Stop naming it does', async () => { await attach() await turn('running') const unnamed = () => host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - expect(await unnamed()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await unnamed()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await stopTurn()).toMatchObject({ ok: true, value: { cancelled: true } }) + const [first, second, named] = await Promise.all([unnamed(), unnamed(), stopTurn()]) + + expect(first).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(second).toMatchObject({ ok: true }) + expect(named).toMatchObject({ ok: true }) + const { items } = await host.journalSnapshot(SESSION) + expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.itemId] : []))).toEqual([ + agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')) + ]) expect(await statusRows()).toEqual([REQUESTED]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index c5fbfe3316a7..5c177b8e654b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -292,13 +292,14 @@ describe('a start that ends before it proves itself', () => { }) expect((await submission(id))?.handedOverAt).toBeUndefined() expect(closeSession).toHaveBeenCalled() - // The rejected send already carries Stop feedback; another host row would duplicate it. + // The taken-back send carries its stopped result, and the accepted Stop its one request row. const snapshot = await host.journalSnapshot(SESSION) expect( projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) ).toMatchObject([ { role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } + { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] }, + { blocks: [{ type: 'text', text: 'Cancellation requested.' }] } ]) expect(dispatch).not.toHaveBeenCalled() expect(startupAttemptOpen()).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts deleted file mode 100644 index c6ea39cd4bda..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary -} from '../../../shared/agent-session-wire' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' -import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' - -let rig: QueuedMessageTestRig - -afterEach(() => rig.dispose()) - -function journal() { - const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!open) { - throw new Error('expected the conversation open') - } - return open -} - -async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { - await journal().appendItem( - { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, - { - kind: 'turn', - turnId, - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(clientMessageId), - ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) - }, - { - fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ) -} - -function watchStatus(): () => AgentSessionStatusSummary | undefined { - const events: AgentSessionStatusEvent[] = [] - rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) - return () => { - for (const event of events.toReversed()) { - if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { - return event.session - } - if (event.type === 'snapshot') { - return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) - } - } - return undefined - } -} - -async function laneDrained(): Promise { - for (let step = 0; step < 5; step += 1) { - await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) - } -} - -async function stoppingTurn(options: { card?: true; refused?: true } = {}) { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - const status = watchStatus() - let cardId: string | undefined - if (options.card) { - await rig.send('queued card', 'queue-if-active').result - await eventually(async () => expect(await rig.drafts()).toHaveLength(1)) - cardId = (await rig.drafts())[0]?.messageId - } - if (options.refused) { - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) - } - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - return { sent, status, cardId, dispatched: rig.dispatch.mock.calls.length } -} - -describe("a message sent while a person's Stop ends the turn", () => { - it.each([ - ['a queued card sent now', true, false], - ['a send', false, false], - ['a send, after a Stop the agent declined', false, true] - ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard, refused) => { - const { sent, status, cardId, dispatched } = await stoppingTurn({ - ...(fromCard ? { card: true as const } : {}), - ...(refused ? { refused: true as const } : {}) - }) - - const result = cardId ? await rig.sendNow(cardId) : await rig.send('one more thing').result - expect(result).toMatchObject({ ok: true }) - await laneDrained() - const held = await rig.submission(result.ok ? result.value.clientMessageId : '') - expect(held).toMatchObject({ dispatchState: 'pending' }) - expect(held?.handedOverAt).toBeUndefined() - expect(rig.dispatch.mock.calls.length).toBe(dispatched) - expect(status()).toMatchObject({ stopping: true }) - - await turn('turn-1', sent, 'interrupted') - - await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) - }) - - // A no-op Stop ends no future turn; later sends may join the opening turn once it appears. - it('hands over a send made after a Stop that stopped nothing once the turn opens, with no Stopping flip', async () => { - rig = await createQueuedMessageTestRig() - const first = await rig.workingSend() - const seen: (true | undefined)[] = [] - rig.host.subscribeStatus({ - id: 'flips', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { - seen.push(event.session.stopping) - } - } - }) - const answer = Promise.withResolvers<{ cancelled: boolean }>() - rig.cancelTurn.mockImplementationOnce(() => answer.promise) - const stopped = rig.stop() - await eventually(() => expect(seen.at(-1)).toBe(true)) - const dispatched = rig.dispatch.mock.calls.length - - const later = rig.send('sent before the turn opened') - answer.resolve({ cancelled: false }) - expect(await stopped).toMatchObject({ ok: true }) - expect(await later.result).toMatchObject({ ok: true }) - const { loop } = rig.host.collaboratorsForTests().conversationDelivery - await eventually(() => expect(loop.isRunning(HOST_TEST_SESSION)).toBe(false)) - expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() - - await rig.settleAccepted(first, 'first') - await turn('turn-1', first, 'running') - await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) - await laneDrained() - expect(seen.at(-1)).toBeUndefined() - expect(seen.slice(seen.lastIndexOf(true) + 1)).not.toContain(true) - }) - - // Settlement without rows must still wake held delivery. - it('hands a held send over when the Stop settles with no row after it', async () => { - rig = await createQueuedMessageTestRig() - await turn('turn-1', await rig.workingSend(), 'running') - await journal().appendStopEvent({ reason: 'user-stop' }, 1) - const settle = journal().stopMarks.beginSettle() - const later = rig.send('sent while the Stop settles') - expect(await later.result).toMatchObject({ ok: true }) - await laneDrained() - const dispatched = rig.dispatch.mock.calls.length - expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() - - journal().stopMarks.settled(settle) - - await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) - }) - - it('holds a send that a Stop overtook between its delivery step and the handover', async () => { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - await laneDrained() - const status = watchStatus() - const step = holdDelivery() - const first = rig.send('steer this in') - expect(await first.result).toMatchObject({ ok: true }) - await step.held - const dispatched = rig.dispatch.mock.calls.length - - const stopped = rig.stop() - const second = rig.send('and this one') - step.release() - expect(await stopped).toMatchObject({ ok: true }) - expect(await second.result).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - await laneDrained() - - expect(rig.dispatch.mock.calls.length).toBe(dispatched) - expect((await rig.submission(second.id))?.handedOverAt).toBeUndefined() - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) - }) - - it('reads Stopping once per commit while a send waits on it', async () => { - const { cardId } = await stoppingTurn({ card: true }) - expect(await rig.sendNow(cardId ?? '')).toMatchObject({ ok: true }) - await laneDrained() - const reads = vi.spyOn(journal(), 'snapshot') - - for (let row = 0; row < 3; row += 1) { - await journal().appendItem( - { provider: 'orca', clientMessageId: `streamed-${row}` }, - { kind: 'status', text: `still streaming ${row}` }, - { - fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ) - await laneDrained() - } - - expect(reads.mock.calls.length).toBeLessThanOrEqual(3) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts index 9d5f94d50335..838e055eddc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts @@ -1,8 +1,11 @@ import { afterEach, expect, it, vi } from 'vitest' import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { JournalStopAcceptor } from '../agent-session-journal/journal-stop-acceptance' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, hostTestOperationId } from './structured-agent-session-host-test-data' import { @@ -61,6 +64,65 @@ it('starts a Codex chat afresh for a message sent right after a Stop reached its expect(rig.dispatch).toHaveBeenCalledTimes(1) }) +it('writes no request for an empty unpublished acquisition', async () => { + let stopping: ReturnType | undefined + await duringStart(() => { + stopping = rig.stop() + }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await expect(rig.host.attach(CALLER, hostTestAttachParams(fence))).rejects.toThrow( + "couldn't restart" + ) + await eventually(() => expect(stopping).toBeDefined()) + await stopping + expect( + (await rig.host.journalSnapshot(SESSION)).items.filter((item) => item.body.kind === 'status') + ).toEqual([]) +}) + +it('keeps the accepted Stop when dispatch rejects during acceptance, ending a late-opening turn', async () => { + rig = await createQueuedMessageTestRig() + rig.dispatch.mockImplementationOnce((input) => + waitForPromiseWithSignal(new Promise(() => {}), input.signal) + ) + const sent = rig.send('hello') + await sent.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledOnce()) + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const accept = JournalStopAcceptor.prototype.accept + vi.spyOn(JournalStopAcceptor.prototype, 'accept').mockImplementationOnce(async function ( + this: JournalStopAcceptor, + ...args + ) { + await eventually(() => expect(journal.submissions()[0]?.dispatchState).toBe('rejected')) + return accept.apply(this, args) + }) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + rig.closeSession.mockImplementationOnce(async () => { + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'late-turn', ordinal: 900 }, + { kind: 'turn', turnId: 'late-turn', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await eventually(() => + expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child ?? null).toBeNull() + ) + expect(rig.closeSession).toHaveBeenCalledOnce() + const snapshot = await rig.host.journalSnapshot(SESSION) + expect(snapshot.items.filter((item) => item.body.kind === 'status')).toMatchObject([ + { body: { text: 'Cancellation requested.' } } + ]) + expect(snapshot.items.find((item) => item.body.kind === 'turn')?.body).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) +}) + it('answers truthfully when the save is slow and an option pick waits behind it', async () => { let stopping: ReturnType | undefined const saving = Promise.withResolvers() @@ -115,6 +177,9 @@ it('answers that it stopped the queued message even when its write throws after expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await rig.submission(first.id)).toMatchObject({ rejection: { kind: 'cancelled' } }) + expect( + (await rig.host.journalSnapshot(SESSION)).items.filter((item) => item.body.kind === 'status') + ).toHaveLength(1) }) it.each(['before the call', 'while the agent prepares it'] as const)( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts index 06767a76e207..4d1c0a0b98ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.ts @@ -75,7 +75,7 @@ export async function acceptStopTarget(ctx: AgentSessionTurnContext): Promise -): Promise> { +): Promise> { const receipt = stopReceipt(ctx) const committedBefore = receipt.isCommitted() try { @@ -83,7 +83,7 @@ export async function acceptChatStop( { ...input, liveTurnId: () => contextStructuredAgentSessionCurrentWork(ctx).activeTurnId() }, committedBefore ? undefined : receipt ) - return { ok: true, value: { turnId: accepted.turnId } } + return { ok: true, value: { turnId: accepted.turnId, withdrawn: accepted.withdrawn } } } catch (error) { return savedAnyway(ctx, receipt, committedBefore, error) ? { ok: true, value: { turnId: null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts deleted file mode 100644 index 35e8f86b8935..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' -import { JournalStopAcceptor } from '../agent-session-journal/journal-stop-acceptance' -import { structuredAgentSessionStopping } from './structured-agent-session-stopping' -import { structuredAgentSessionNamedTurnScope } from './structured-agent-session-turn-stop-notes' -import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - QUEUED_RIG_CALLER, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' -import { - openRigTurnFor, - withdrawnByStop -} from './structured-agent-session-queued-rig-turn.test-fixture' - -let rig: QueuedMessageTestRig - -afterEach(() => rig.dispose()) - -const LATER_TURN: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'thread-1', - turnId: 'turn-later', - ordinal: 999 -} - -function journal() { - const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!open) { - throw new Error('expected the conversation open') - } - return open -} - -function stopEvents(): JournalStopEvent[] { - const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - return since.rows.flatMap((row) => - row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] - ) -} - -function childPhase() { - return rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase -} - -function fence(): number { - return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 -} - -async function laterTurn() { - const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) - return items - .map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'turn-later') -} - -function laterTurnEndRows() { - const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - return since.rows.flatMap((row) => { - const turn = row.kind === 'item' ? readAgentJournalTurn(row.body) : undefined - return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] - }) -} - -async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') { - await journal().appendItem( - LATER_TURN, - { - kind: 'turn', - turnId: 'turn-later', - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(clientMessageId), - ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) - }, - { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -async function queuedDraft(text: string): Promise { - const queued = await rig.send(text, 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) - } - return queued.value.queued.messageId -} - -async function stopOfStart(options: { held?: true } = {}): Promise { - rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - rig.send('work on this') - await eventually(() => expect(childPhase()).toBe('starting')) - const held = options.held ? await queuedDraft('queued behind the start') : undefined - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) - expect(stopEvents()[0]).not.toHaveProperty('turnId') - await eventually(() => expect(childPhase()).toBeUndefined()) - return held -} - -async function mailTurn(): Promise { - const handedOver = rig.dispatch.mock.calls.length - const mail = rig.send('mail for the retry') - await mail.result - await rig.proveStart() - await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1)) - await turnOpenedBy(mail.id) -} - -/** Close hides the pause row while preserving the derived personal Stop hold. */ -async function stillStopped(): Promise { - expect(await rig.queuePause()).toBeNull() - return structuredQueuePauses(journal()).some((pause) => pause.reason === 'stopped') -} - -async function evictedAt(): Promise { - let atClose: JournalStopEvent[] = [] - rig.closeSession.mockImplementationOnce(async () => { - // Yield until the pre-kill event lands ahead of child-generated rows. - await new Promise((resolve) => setImmediate(resolve)) - atClose = stopEvents() - return true - }) - await rig.host.close(HOST_TEST_SESSION, 'evict') - return atClose.map((event) => event.reason) -} - -async function expectNews(): Promise { - const turn = await laterTurn() - expect(turn).toMatchObject({ state: 'interrupted' }) - expect(turn).not.toHaveProperty('outcome') -} - -describe('a Stop of a start that never landed binds no later turn', () => { - it("writes the host's event when it evicts a mail turn, which reads as news", async () => { - await stopOfStart() - await mailTurn() - - expect(await evictedAt()).toEqual(['user-stop', 'evict']) - await expectNews() - }) - - it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => { - await stopOfStart() - const mail = rig.send('mail for the retry') - await mail.result - await turnOpenedBy(mail.id) - - await turnOpenedBy(mail.id, 'interrupted') - - await expectNews() - }) - - it('settles a crash of a mail turn on relaunch as news', async () => { - await stopOfStart() - await mailTurn() - const owner = fence() - rig.crashRestartHostProcess() - await rig.host.journalSnapshot(HOST_TEST_SESSION) - - await settleStaleStructuredAgentSessionState({ - journal: journal(), - sessionId: HOST_TEST_SESSION, - fence: owner + 1, - acquisitionGeneration: 'generation-2', - deathEvidence: { - kind: 'exit-observed', - detail: 'the relaunch proved the old child gone', - observedAt: Date.now() + 60_000, - ownerFence: owner - } - }) - - await expectNews() - }) - - it("writes the host's event when it evicts the turn of a card the Stop held, which Resume sent", async () => { - const held = (await stopOfStart({ held: true }))! - expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - await turnOpenedBy(await rig.handoffId(held)) - - expect(await evictedAt()).toEqual(['user-stop', 'evict']) - await expectNews() - }) -}) - -describe('a Stop naming no turn binds the turn live as its event is written', () => { - const FIRST_TURN: AgentJournalItemIdentity = { ...LATER_TURN, turnId: 'turn-first', ordinal: 998 } - - function firstTurn(state: 'running' | 'completed') { - return journal().appendItem( - FIRST_TURN, - { - kind: 'turn', - turnId: 'turn-first', - startedAt: 1, - ...(state === 'running' ? { state } : { state, completedAt: 2 }) - }, - { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - - it('names and binds the turn that opened while its save waited, never the one before', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await firstTurn('running') - const saving = Promise.withResolvers() - const entered = Promise.withResolvers() - const accept = JournalStopAcceptor.prototype.accept - const held = vi - .spyOn(JournalStopAcceptor.prototype, 'accept') - .mockImplementation(async function (this: JournalStopAcceptor, ...args) { - entered.resolve() - await saving.promise - return accept.apply(this, args) - }) - const stopping = rig.stop() - await entered.promise - await firstTurn('completed') - await turnOpenedBy(working) - saving.resolve() - - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - held.mockRestore() - expect(stopEvents()).toEqual([expect.objectContaining({ turnId: 'turn-later' })]) - expect(rig.cancelTurn).toHaveBeenCalledOnce() - const note = journal() - .snapshot() - .items.find((item) => item.body.kind === 'status') - expect(note?.turnScope).toEqual(structuredAgentSessionNamedTurnScope(journal(), 'turn-later')) - const snapshot = await rig.host.journalSnapshot(HOST_TEST_SESSION) - expect(structuredAgentSessionStopping(journal(), snapshot.items, journal().submissions())).toBe( - true - ) - await turnOpenedBy(working, 'interrupted') - expect(laterTurnEndRows()).toEqual([ - expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) - ]) - const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) - const first = items - .map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'turn-first') - expect(first).toMatchObject({ state: 'completed' }) - expect(first).not.toHaveProperty('outcome', 'cancellation') - }) -}) - -describe('a Stop pressed before its send opened a turn binds only the turn it stopped', () => { - async function stopBeforeTheTurnShowed( - answer: Awaited> = { cancelled: true } - ): Promise<{ stopped: string; held: string }> { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - const held = await queuedDraft('queued behind the turn') - rig.cancelTurn.mockResolvedValueOnce(answer) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - return { stopped, held } - } - - it('binds the turn its interrupt took, whose end lands after the Stop answered', async () => { - const { stopped } = await stopBeforeTheTurnShowed({ cancelled: true, turnId: 'turn-later' }) - await rig.settleAccepted(stopped, 'stopped') - - await turnOpenedBy(stopped, 'interrupted') - - expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - }) - - it('reads a turn that ends while the Stop settles as interrupted from its first end row', async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - rig.cancelTurn.mockImplementationOnce(async () => { - await turnOpenedBy(stopped) - await turnOpenedBy(stopped, 'interrupted') - return { cancelled: true, turnId: 'turn-later' } - }) - - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(laterTurnEndRows()).toEqual([ - expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) - ]) - expect(journal().runningTurn()?.turnId ?? null).toBeNull() - }) - - it('binds no turn that opens after a Stop that stopped nothing', async () => { - const { stopped } = await stopBeforeTheTurnShowed({ cancelled: false }) - await rig.settleAccepted(stopped, 'stopped') - - await turnOpenedBy(stopped) - await turnOpenedBy(stopped, 'interrupted') - - await expectNews() - }) - - it("writes the host's event when it evicts the turn of the card Resume sent", async () => { - const { stopped, held } = await stopBeforeTheTurnShowed() - await rig.settleAccepted(stopped, 'stopped') - expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - await turnOpenedBy(await rig.handoffId(held)) - - expect(await evictedAt()).toEqual(['user-stop', 'evict']) - await expectNews() - }) - - it('reads a mail turn the child end cut as news', async () => { - const { stopped } = await stopBeforeTheTurnShowed() - await rig.settleAccepted(stopped, 'stopped') - const mail = rig.send('mail for the lead') - await mail.result - await turnOpenedBy(mail.id) - - await turnOpenedBy(mail.id, 'interrupted') - - await expectNews() - }) -}) - -describe('a press that writes no Stop event of its own', () => { - async function laterTurnAfterAnEarlierStop(): Promise { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - await withdrawnByStop(rig, stopped) - const sent = await rig.workingSend() - await rig.settleAccepted(sent, 'sent') - await turnOpenedBy(sent) - return sent - } - - it('reopens no earlier Stop: a late Stop naming a turn already over interrupts nothing', async () => { - await laterTurnAfterAnEarlierStop() - const earlier = journal().stopMarks.latest() - const interrupts = rig.cancelTurn.mock.calls.length - - const late = await rig.host.cancel(QUEUED_RIG_CALLER, { - envelope: rig.envelope({ turnId: 'turn-gone' }, 'agentSession.cancel', hostTestOperationId()), - turnId: 'turn-gone' - }) - - expect(late).toMatchObject({ ok: true, value: { turnId: 'turn-gone', cancelled: false } }) - expect(rig.cancelTurn).toHaveBeenCalledTimes(interrupts) - expect(journal().stopMarks.latest()).toBe(earlier) - expect(await laterTurn()).toMatchObject({ state: 'running' }) - }) - - it('reopens no earlier Stop: a press whose acceptance failed interrupts nothing', async () => { - await laterTurnAfterAnEarlierStop() - const earlier = journal().stopMarks.latest() - const interrupts = rig.cancelTurn.mock.calls.length - vi.spyOn(journal().stops, 'accept').mockRejectedValueOnce(new Error('disk full')) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - expect(await rig.stop()).toMatchObject({ ok: false }) - - expect(rig.cancelTurn).toHaveBeenCalledTimes(interrupts) - expect(journal().stopMarks.latest()).toBe(earlier) - expect(await laterTurn()).toMatchObject({ state: 'running' }) - }) -}) - -describe("a Stop's settle that ends the turn its interrupt took", () => { - it("ends a turn still running at the Stop's settle, as the Stop's, once", async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - await rig.settleAccepted(stopped, 'stopped') - await turnOpenedBy(stopped) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-later' }) - - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(journal().runningTurn()?.turnId ?? null).toBeNull() - }) -}) - -describe('a host stop with no turn running after a Stop that named none', () => { - it('defers to that Stop while it settles, and not after', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - let whileSettling: boolean | undefined - rig.cancelTurn.mockImplementationOnce(async () => { - whileSettling = journal().stopMarks.personStopDecides(null) - return { cancelled: true } - }) - - expect(await rig.stop()).toMatchObject({ ok: true }) - - expect(whileSettling).toBe(true) - expect(journal().stopMarks.personStopDecides(null)).toBe(false) - }) - - it.each([ - ['stopped nothing', { cancelled: false }], - ['took', { cancelled: true }] - ] as const)( - 'writes nothing while the Stop, which %s, still pauses the queue, and the card stays held', - async (_, answer) => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - const card = await queuedDraft('queued behind the send') - rig.cancelTurn.mockResolvedValueOnce(answer) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - - expect(await evictedAt()).toEqual(['user-stop']) - expect(await stillStopped()).toBe(true) - expect(await rig.handoff(card)).toBeUndefined() - } - ) - - it('writes nothing after a Stop whose kill failed, and the card stays held', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - const card = await queuedDraft('queued behind the send') - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - expect(await evictedAt()).toEqual(['user-stop']) - expect(await stillStopped()).toBe(true) - expect(await rig.handoff(card)).toBeUndefined() - }) - - it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - expect(await rig.stop()).toMatchObject({ ok: true }) - await openRigTurnFor(rig, stopped) - await openRigTurnFor(rig, stopped, 'interrupted') - const mail = rig.send('mail for the lead') - await mail.result - await eventually(async () => - expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() - ) - - expect(await evictedAt()).toEqual(['user-stop', 'evict']) - }) -}) - -describe('a rewind that restates a turnless Stop', () => { - it('binds no turn opened after the rewind', async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - await rig.settleAccepted(stopped, 'stopped') - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - const stoppedTurn = { ...LATER_TURN, turnId: 'turn-stopped', ordinal: 998 } - const ended = { - kind: 'turn' as const, - turnId: 'turn-stopped', - state: 'interrupted' as const, - userItemId: agentJournalSubmissionKey(stopped) - } - await journal().appendItem( - stoppedTurn, - { ...ended, state: 'running', startedAt: Date.now() }, - scope - ) - await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) - - await journal().replaceEpochItems('handle_forked', 1, [ - { - identity: stoppedTurn, - body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' }, - ownerFence: 1 - } - ]) - const mail = rig.send('mail after the rewind') - await mail.result - await turnOpenedBy(mail.id) - await turnOpenedBy(mail.id, 'interrupted') - - await expectNews() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts deleted file mode 100644 index b056525bfc6e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' -import { structuredQueuePauses } from './structured-agent-session-queued-pause' - -let rig: QueuedMessageTestRig -const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = globalThis - -afterEach(() => rig.dispose()) - -const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } - -function journal() { - const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!open) { - throw new Error('expected the conversation open') - } - return open -} - -function stopEvents(): JournalStopEvent[] { - const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - return since.rows.flatMap((row) => - row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] - ) -} - -function stopEventsAtClose(onClose?: () => void): { events: JournalStopEvent[] | null } { - const seen: { events: JournalStopEvent[] | null } = { events: null } - rig.closeSession.mockImplementationOnce(async () => { - onClose?.() - // Yield until the pre-kill event lands ahead of child-generated rows. - await new Promise((resolve) => setImmediate(resolve)) - seen.events = stopEvents() - return true - }) - return seen -} - -async function awaitCloseStep(pending: Promise, phase: string): Promise { - let timeout: ReturnType | undefined - const deadline = new Promise((_, reject) => { - timeout = realSetTimeout(() => reject(new Error(`Timed out awaiting ${phase}`)), 2_000) - }) - try { - return await Promise.race([pending, deadline]) - } finally { - realClearTimeout(timeout) - } -} - -async function closePastSlowDrain( - cause: 'user-close' | 'evict', - prepare: () => void | Promise -) { - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) - const began = Date.now() - const entered = Promise.withResolvers() - const closingProvider = Promise.withResolvers() - const runtime = rig.host.collaboratorsForTests().runtimeState - const flush = runtime.flushEventSink.bind(runtime) - let closing: ReturnType | undefined - const flushing = vi.spyOn(runtime, 'flushEventSink') - try { - await awaitCloseStep(Promise.resolve().then(prepare), 'slow sink preparation') - flushing.mockImplementationOnce((sessionId) => { - const pending = flush(sessionId) - entered.resolve() - return pending - }) - const atClose = stopEventsAtClose(closingProvider.resolve) - const callsBefore = rig.closeSession.mock.calls.length - closing = rig.host.close(HOST_TEST_SESSION, cause) - await awaitCloseStep(entered.promise, 'close entering the sink drain') - await new Promise((resolve) => setImmediate(resolve)) - await vi.advanceTimersByTimeAsync(999) - expect(rig.closeSession).toHaveBeenCalledTimes(callsBefore) - await vi.advanceTimersByTimeAsync(1) - await awaitCloseStep(closingProvider.promise, 'provider close at 1,000ms') - expect(Date.now() - began).toBe(1_000) - return atClose - } finally { - try { - // Finish the held sink delay before teardown drains it. - await vi.advanceTimersByTimeAsync(Math.max(0, 1_500 - (Date.now() - began))) - } finally { - flushing.mockRestore() - vi.useRealTimers() - } - await awaitCloseStep(Promise.resolve(closing), 'host close cleanup') - await awaitCloseStep(rig.host.flushStreamedEvents(HOST_TEST_SESSION), 'event sink cleanup') - } -} - -async function runningTurn(turnId = 'turn-1'): Promise { - const working = await rig.workingSend() - await journal().appendItem( - { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, - { kind: 'turn', turnId, state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return working -} - -async function queuedDraft(text: string): Promise { - const queued = await rig.send(text, 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) - } - return queued.value.queued.messageId -} - -function idleSweep() { - return rig.host.collaboratorsForTests().lifetime.idleSweep -} - -describe('every Stop entry writes its event, with its reason, before it ends the child', () => { - it.each([['user-close' as const], ['evict' as const]])( - 'a %s close of a running turn', - async (cause) => { - rig = await createQueuedMessageTestRig() - await runningTurn() - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, cause) - - expect(atClose.events).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) - } - ) - - it("a person's Stop of a running turn", async () => { - rig = await createQueuedMessageTestRig() - await runningTurn() - let atInterrupt: JournalStopEvent[] = [] - rig.cancelTurn.mockImplementationOnce(async () => { - atInterrupt = stopEvents() - return { cancelled: true } - }) - - await rig.stop() - - expect(atInterrupt).toEqual([ - expect.objectContaining({ reason: 'user-stop', turnId: 'turn-1', at: expect.any(Number) }) - ]) - }) - - it('the idle sweep stopping a start, with its send, that never landed', async () => { - rig = await createQueuedMessageTestRig({ - starting: true, - restartable: true, - idleSweep: MANUAL_IDLE_SWEEP - }) - rig.send('work on this') - await eventually(async () => - expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( - 'starting' - ) - ) - const atClose = stopEventsAtClose() - - await idleSweep().tick() - - expect(atClose.events).toEqual([{ reason: 'host-stop', at: expect.any(Number) }]) - }) - - it('writes nothing when it ends nothing: a close of a chat at rest', async () => { - rig = await createQueuedMessageTestRig() - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'user-close') - - expect(atClose.events).toEqual([]) - }) - - it("writes nothing when the host evicts a turn a person's Stop is still ending", async () => { - rig = await createQueuedMessageTestRig() - await runningTurn() - expect(await rig.stop()).toMatchObject({ ok: true }) - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'evict') - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) - expect(items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) - }) - - // Timed-out drain may still owe the accepted send’s first turn row. - it("writes a person's close while the running turn's row waits behind a slow sink", async () => { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - const open = journal() - const append = open.appendItem.bind(open) - let held = false - const heldSink = Promise.withResolvers() - const atClose = await closePastSlowDrain('user-close', async () => { - vi.spyOn(open, 'appendItem').mockImplementation( - async (...args: Parameters) => { - if (!held && args[1].kind === 'turn' && args[1].state === 'running') { - held = true - const delay = new Promise((resolve) => setTimeout(resolve, 1_500)) - heldSink.resolve() - await delay - } - return append(...args) - } - ) - rig.host - .collaboratorsForTests() - .runtimeState.eventSinkFor(HOST_TEST_SESSION) - .sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, - { - kind: 'turn', - turnId: 'turn-1', - state: 'running', - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(sent) - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await heldSink.promise - await rig.settleAccepted(sent, 'turn-1') - }) - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close']) - }, 20_000) - - // Only unturned accepted sends owe a first turn row; steers belong to an existing turn. - it.each([ - ['', false], - [', its last send a steer into the stopped turn', true] - ])( - 'writes nothing when the drain runs long as it evicts a chat at rest%s', - async (_label, steered) => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - const opener = agentJournalSubmissionKey(working) - const identity = { - provider: 'codex' as const, - threadId: 'thread-1', - turnId: 'turn-1', - ordinal: 999 - } - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, - scope - ) - if (steered) { - const steer = rig.send('steer the running turn') - await steer.result - await eventually(async () => - expect((await rig.submission(steer.id))?.handedOverAt).toBeDefined() - ) - await rig.settleAccepted(steer.id, 'turn-1') - expect( - journal() - .snapshot() - .items.find((item) => item.itemId === agentJournalSubmissionKey(steer.id))?.turnScope - ).toMatchObject({ kind: 'turn' }) - } - await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'turn-1') - await journal().appendItem( - identity, - { - kind: 'turn', - turnId: 'turn-1', - state: 'interrupted', - completedAt: Date.now(), - userItemId: opener - }, - scope - ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) - const drained = sink.drained.bind(sink) - const atClose = await closePastSlowDrain('evict', () => { - vi.spyOn(sink, 'drained') - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) - ) - .mockImplementation(drained) - }) - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toBeNull() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toContain('stopped') - }, - 20_000 - ) - - it('writes nothing when the drain fails as it evicts a chat at rest', async () => { - rig = await createQueuedMessageTestRig() - const working = await runningTurn() - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') - await journal().appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, - { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - expect(journal().runningTurn()?.turnId ?? null).toBeNull() - const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockResolvedValueOnce({ ok: false, error: new Error('drain lost once') }) - .mockImplementation(drained) - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'evict') - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - }) - - it.each([['user-close' as const], ['evict' as const]])( - 'writes one event for a close (%s) whose exit was unproven, and none for a stop that joins it', - async (cause) => { - rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) - await runningTurn() - rig.closeSession.mockResolvedValueOnce(false) - const session = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! - - await rig.host.close(HOST_TEST_SESSION, cause).catch(() => undefined) - expect(session().child?.close).toMatchObject({ cause }) - expect(stopEvents()).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) - - await rig.host['tasks'].serialize(HOST_TEST_SESSION, () => - rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'host-stop' }) - ) - - expect(session().child).toBeNull() - expect(stopEvents().map((event) => event.reason)).toEqual([cause]) - expect(session().lastEndedChild?.cause).toBe(cause) - } - ) - - it("writes nothing at quit, whose resume marker's trigger records why", async () => { - rig = await createQueuedMessageTestRig() - await runningTurn() - const atClose = stopEventsAtClose() - - await rig.host.flushAllStreamedEvents({ trigger: 'quit' }) - - expect(atClose.events).toEqual([]) - }) -}) - -describe("a person's Stop pause and the Stop events after it", () => { - it('holds through an idle eviction of the chat at rest, which writes nothing', async () => { - rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) - const working = await rig.workingSend() - const held = await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - const atClose = stopEventsAtClose() - - await idleSweep().tick() - - expect(rig.closeSession).toHaveBeenCalledTimes(1) - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(await rig.handoff(held)).toBeUndefined() - }) - - it('holds through an idle eviction that retires a send whose reply was lost', async () => { - rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) - const working = await rig.workingSend() - await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') - rig.dispatch.mockRejectedValueOnce(new Error('reply lost')) - const lost = rig.send('sent as the reply was lost') - await lost.result - await eventually(async () => - expect(await rig.submission(lost.id)).toMatchObject({ dispatchState: 'unknown' }) - ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - const atClose = stopEventsAtClose() - - await idleSweep().tick() - - expect(rig.closeSession).toHaveBeenCalledTimes(1) - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - }) - - it('holds through the sweep stopping a start that carries no send, which writes nothing', async () => { - rig = await createQueuedMessageTestRig({ starting: true, idleSweep: MANUAL_IDLE_SWEEP }) - expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( - 'starting' - ) - await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) - expect(journal().queuedMessages.userStopInForce()).not.toBeNull() - const atClose = stopEventsAtClose() - - await idleSweep().tick() - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(journal().queuedMessages.userStopInForce()).not.toBeNull() - }) - - it('ends when a host eviction ends a running turn: that Stop event is later', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - await rig.send('mail for the lead').result - await journal().appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, - { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['stopped']) - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'evict') - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'evict']) - expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) - }) - - it('ends when the host stops a start that never landed: that Stop event is later', async () => { - rig = await createQueuedMessageTestRig({ - starting: true, - restartable: true, - idleSweep: MANUAL_IDLE_SWEEP - }) - const working = await rig.workingSend() - const held = await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'stopped') - await idleSweep().tick() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - rig.send('mail for the lead') - await eventually(async () => - expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( - 'starting' - ) - ) - const atClose = stopEventsAtClose() - - await idleSweep().tick() - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop']) - expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts deleted file mode 100644 index 9cad338b353c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' -import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' -import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' -import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' -import { - QUEUED_RIG_CALLER, - createQueuedMessageTestRig, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' - -const TURN = 'turn-1' -const CLAUDE_TURN: AgentJournalItemIdentity = { - provider: 'claude', - sessionId: 'provider-session-1', - uuid: 'uuid-turn' -} -const CODEX_TURN: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'thread-1', - turnId: TURN, - ordinal: 999 -} -const NEXT_TURN = 'turn-2' -const CODEX_NEXT_TURN: AgentJournalItemIdentity = { - ...CODEX_TURN, - turnId: NEXT_TURN, - ordinal: 1000 -} - -let rig: QueuedMessageTestRig - -afterEach(() => rig.dispose()) - -function journal() { - const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!open) { - throw new Error('expected the conversation open') - } - return open -} - -async function runningTurn( - identity: AgentJournalItemIdentity, - options: { stopEndsSession?: true } = {} -): Promise { - rig = await createQueuedMessageTestRig(options) - await rig.workingSend() - await journal().appendItem( - identity, - { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -/** Reopen after a crash with the turn end unwritten; prove the old child gone before settlement. */ -async function restartAndSettle( - proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent' -): Promise { - rig.crashRestartHostProcess() - await rig.host.journalSnapshot(HOST_TEST_SESSION) - const now = Date.now() - const deathEvidence: AgentSessionDeathEvidence | null = - proof === 'unproven' - ? null - : { - kind: proof, - detail: 'the relaunch proved the old child gone', - observedAt: now + 60_000, - ownerFence: 1, - lastProvenAliveAt: now - 20_000 - } - await settleStaleStructuredAgentSessionState({ - journal: journal(), - sessionId: HOST_TEST_SESSION, - fence: 2, - acquisitionGeneration: 'generation-2', - deathEvidence - }) -} - -function settled(turnId?: string) { - const { items } = journal().snapshot() - const turn = items - .map((item) => readAgentJournalTurn(item.body)) - .find((entry) => entry && (turnId === undefined || entry.turnId === turnId)) - const [timing] = [...selectStructuredAgentSettledTurns(items).values()] - const verdict = turn - ? agentTurnVerdict({ state: turn.state, outcome: turn.outcome ?? null }) - : null - return { - turn, - label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, - mark: verdict - ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) - : null, - errorRows: items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) - } -} - -describe('a restart between a Stop and its turn end', () => { - it.each([ - ['a Claude Stop before its result arrives', CLAUDE_TURN], - ['a Codex Stop before turn/completed', CODEX_TURN] - ])('reads Interrupted after N, marked interrupted: %s', async (_label, identity) => { - await runningTurn(identity) - expect(await rig.stop()).toMatchObject({ ok: true }) - - await restartAndSettle() - - const { turn, label, mark, errorRows } = settled() - expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(label).toMatch(/^Interrupted after /) - expect(mark).toBe('interrupted') - expect(errorRows).toEqual([]) - }) - - it.each([ - ['Claude', CLAUDE_TURN], - ['Codex', CODEX_TURN] - ])('reads Interrupted after N for a %s Stop that named its turn', async (_label, identity) => { - await runningTurn(identity) - rig.cancelTurn.mockImplementationOnce(() => new Promise(() => undefined)) - void rig.host.cancel(QUEUED_RIG_CALLER, { - envelope: rig.envelope({ turnId: TURN }, 'agentSession.cancel', hostTestOperationId()), - turnId: TURN - }) - await expect.poll(() => journal().stopMarks.latest()?.event.turnId).toBe(TURN) - - await restartAndSettle() - - expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(settled().label).toMatch(/^Interrupted after /) - }) - - it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { - await runningTurn(CODEX_TURN) - rig.closeSession.mockImplementationOnce(() => Promise.reject(new Error('host died'))) - await expect(rig.host.close(HOST_TEST_SESSION, 'user-close')).rejects.toThrow() - expect(journal().stopMarks.latest()?.event).toMatchObject({ - reason: 'user-close', - turnId: TURN - }) - - await restartAndSettle() - - const { turn, label, mark, errorRows } = settled() - expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(label).toMatch(/^Interrupted after /) - expect(mark).toBe('interrupted') - expect(errorRows).toEqual([]) - }) - - it('reads Worked for N, marked failed, when nobody stopped it', async () => { - await runningTurn(CODEX_TURN) - - await restartAndSettle() - - const { turn, label, mark, errorRows } = settled() - expect(turn).toMatchObject({ state: 'interrupted' }) - expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Worked for /) - expect(mark).toBe('failed') - expect(errorRows).toEqual([ - expect.stringContaining('stopped while this response was in progress') - ]) - }) - - it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { - // Stop attributes an end; it never proves the turn ended. - await runningTurn(CODEX_TURN) - expect(await rig.stop()).toMatchObject({ ok: true }) - - await restartAndSettle('unproven') - - const { turn, mark } = settled() - expect(turn).toMatchObject({ state: 'unverifiable' }) - expect(mark).toBe('unconfirmed') - }) - - // Refusing Stop for old turn X must never attribute current turn Y’s end to the user. - it('reads Failed for the turn running when the provider refused a Stop naming the one before it', async () => { - await runningTurn(CODEX_TURN) - rig.cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { - detail: { - text: `expected active turn id ${TURN} but found ${NEXT_TURN}`, - audience: 'person' - }, - turnNotRunning: true - } - }) - const fields = { turnId: TURN } - expect( - await rig.host.cancel(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), - ...fields - }) - ).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-stop', turnId: TURN }) - await journal().appendItem( - CODEX_TURN, - { - kind: 'turn', - turnId: TURN, - state: 'completed', - outcome: 'success', - completedAt: Date.now() - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal().appendItem( - CODEX_NEXT_TURN, - { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() - 10_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - await restartAndSettle('exit-observed') - - const { turn, mark } = settled(NEXT_TURN) - expect(turn).toMatchObject({ state: 'interrupted' }) - expect(turn).not.toHaveProperty('outcome') - expect(mark).toBe('failed') - }) - - it('reads Interrupted after N when the provider refused a Stop that ends its child', async () => { - await runningTurn(CLAUDE_TURN, { stopEndsSession: true }) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) - expect(rig.closeSession).toHaveBeenCalled() - - await restartAndSettle() - - expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - }) - - it('reads a turn a send made after a Stop pressed before any turn showed as no Stop of its', async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() - await rig.settleAccepted(stopped, 'stopped') - const next = rig.send('sent after the Stop') - await next.result - await rig.settleAccepted(next.id, 'next') - await journal().appendItem( - CODEX_TURN, - { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - await restartAndSettle('exit-observed') - - expect(settled().turn).toMatchObject({ state: 'interrupted' }) - expect(settled().turn).not.toHaveProperty('outcome') - }) - - it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: TURN }) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() - await rig.settleAccepted(stopped, 'stopped') - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - const opener = agentJournalSubmissionKey(stopped) - await journal().appendItem( - CODEX_TURN, - { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now(), userItemId: opener }, - scope - ) - await journal().appendItem( - CODEX_TURN, - { - kind: 'turn', - turnId: TURN, - state: 'interrupted', - completedAt: Date.now() + 1, - userItemId: opener - }, - scope - ) - expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) - const drained = rig.send('drained after the Stop') - await drained.result - await rig.settleAccepted(drained.id, 'drained') - await journal().appendItem( - CODEX_NEXT_TURN, - { - kind: 'turn', - turnId: NEXT_TURN, - state: 'running', - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(drained.id) - }, - scope - ) - - await restartAndSettle('exit-observed') - - expect(settled(NEXT_TURN).turn).toMatchObject({ state: 'interrupted' }) - expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') - }) - - // Turnless Stop binding is memory-only; restart finds it unsettled and binds no turn. - it('reads the turn a turnless Stop was settling when Orca died as no Stop of its', async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - rig.cancelTurn.mockImplementationOnce(async () => { - await journal().appendItem( - CODEX_TURN, - { - kind: 'turn', - turnId: TURN, - state: 'running', - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(stopped) - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return new Promise(() => undefined) - }) - void rig.stop() - await expect.poll(() => journal().runningTurn()?.turnId ?? null).toBe(TURN) - expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() - - await restartAndSettle('exit-observed') - - expect(settled(TURN).turn).toMatchObject({ state: 'interrupted' }) - expect(settled(TURN).turn).not.toHaveProperty('outcome') - }) - - it('writes nothing for a Stop pressed again after the provider refused one', async () => { - await runningTurn(CODEX_TURN) - rig.cancelTurn.mockResolvedValueOnce({ - cancelled: false, - refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } - }) - await rig.stop() - const first = journal().stopMarks.latest() - - await rig.stop() - - expect(journal().stopMarks.latest()).toEqual(first) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index e8ea1923a128..c7ee7dcb14ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -1,12 +1,8 @@ -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' -import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' import { createQueuedMessageTestRig, eventually, @@ -42,14 +38,6 @@ function stopEvents(): JournalStopEvent[] { ) } -async function queuedDraft(text: string): Promise { - const queued = await rig.send(text, 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) - } - return queued.value.queued.messageId -} - async function turnRow(turnId: string, state: 'running' | 'interrupted', userId?: string) { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, @@ -60,29 +48,54 @@ async function turnRow(turnId: string, state: 'running' | 'interrupted', userId? startedAt: 1, ...(userId ? { userItemId: agentJournalSubmissionKey(userId) } : {}) }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { + fence: rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) } -function withdraw(clientMessageId: string) { - return rig.host.settleLateDispatch({ - sessionId: HOST_TEST_SESSION, - clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) +describe("a Stop's event", () => { + it('publishes the request note while the native interrupt is still unanswered', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const interrupt = Promise.withResolvers<{ cancelled: boolean }>() + rig.cancelTurn.mockImplementationOnce(() => interrupt.promise) + const stopped = rig.stop() + try { + await eventually(() => + expect( + journal() + .snapshot() + .items.filter( + (item) => item.body.kind === 'status' && item.body.text === 'Cancellation requested.' + ) + ).toHaveLength(1) + ) + expect(rig.cancelTurn).toHaveBeenCalledOnce() + } finally { + interrupt.resolve({ cancelled: true }) + await stopped + } + expect( + journal() + .snapshot() + .items.filter( + (item) => item.body.kind === 'status' && item.body.text === 'Cancellation requested.' + ) + ).toHaveLength(1) }) -} -describe("a Stop's event", () => { it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() + await turnRow('turn-1', 'running') let atInterrupt: JournalStopEvent[] = [] rig.cancelTurn.mockImplementationOnce(async () => { atInterrupt = stopEvents() return { cancelled: true } }) - const fields = { turnId: 'turn-named' } + const fields = { turnId: 'turn-1' } const operationId = hostTestOperationId() const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { envelope: rig.envelope(fields, 'agentSession.cancel', operationId), @@ -93,215 +106,10 @@ describe("a Stop's event", () => { expect(atInterrupt).toEqual([ { reason: 'user-stop', - turnId: 'turn-named', + turnId: 'turn-1', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } ]) }) - - it("lands before the rows the interrupt causes: the stopped turn's end comes after it", async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - let turnEnd: number | undefined - rig.cancelTurn.mockImplementationOnce(async () => { - turnEnd = (await turnRow('turn-1', 'interrupted')).cursor.sequence - return { cancelled: true } - }) - await rig.stop() - const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) - const stopRow = since.ok - ? since.rows.find((row) => row.kind === 'tombstone' && row.stopEvent) - : undefined - expect(stopRow?.seq).toBeLessThan(turnEnd ?? 0) - }) - - it('a Stop whose acceptance cannot be saved interrupts nothing and asks to try again', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - vi.spyOn(journal().stops, 'accept').mockRejectedValue(new Error('disk I/O error')) - const refused = await rig.stop() - expect(refused).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'stopFailed' }, - message: expect.stringMatching(/^Couldn't stop .+\. Try again\.$/) - } - }) - expect(rig.cancelTurn).not.toHaveBeenCalled() - expect(rig.closeSession).not.toHaveBeenCalled() - expect(stopEvents()).toEqual([]) - warned.mockRestore() - }) - - it('at an agent still starting, reaches the journal before the start is ended, and holds a card queued before it', async () => { - rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) - rig.send('work on this') - await eventually(() => - expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( - 'starting' - ) - ) - const held = await queuedDraft('queued while it starts') - let atEnd: JournalStopEvent[] | undefined - rig.closeSession.mockImplementationOnce(async () => { - atEnd = stopEvents() - return true - }) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await observationClock.observe(250) - expect(await rig.handoff(held)).toBeUndefined() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(rig.cancelTurn).not.toHaveBeenCalled() - expect(rig.dispatch).not.toHaveBeenCalled() - expect(rig.closeSession).toHaveBeenCalledTimes(1) - expect(atEnd).toEqual([ - { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } - ]) - }) - - it('is written by an idle Stop only when it withdrew a send', async () => { - rig = await createQueuedMessageTestRig() - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(stopEvents()).toEqual([]) - // Hold the lane so Stop precedes the send’s requested handover. - const release = holdLane(rig.host, HOST_TEST_SESSION) - const waiting = rig.send('waits for its handover') - const stopped = rig.stop() - release() - expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await rig.submission(waiting.id)).toMatchObject({ dispatchState: 'rejected' }) - expect(rig.dispatch).not.toHaveBeenCalled() - expect(rig.cancelTurn).not.toHaveBeenCalled() - expect(stopEvents()).toEqual([ - { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } - ]) - }) - - it('a second press while the first interrupt lands writes nothing: a card queued between them sends normally', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - const between = await queuedDraft('queued between the presses') - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(stopEvents()).toHaveLength(1) - await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) - }) - - it('a second press once the turn shows, after a first before it did, writes nothing: a card queued between sends', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await rig.stop() - const between = await queuedDraft('queued between the presses') - await turnRow('turn-1', 'running', working) - await rig.stop() - expect(stopEvents()).toHaveLength(1) - await rig.settleAccepted(working, 'stopped') - await turnRow('turn-1', 'interrupted') - await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) - }) - - it('a second press after a card sent into the turn settled unknown writes again', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await rig.stop() - await turnRow('turn-1', 'running', working) - const steered = await queuedDraft('sent into the turn between the presses') - await rig.sendNow(steered) - await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) - await rig.host.settleLateDispatch({ - sessionId: HOST_TEST_SESSION, - clientMessageId: await rig.handoffId(steered), - state: 'unknown', - reason: 'the provider never answered' - }) - await rig.stop() - expect(stopEvents()).toHaveLength(2) - }) - - it('a second press after a card was sent into the turn writes again, and the card stays sent', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await rig.stop() - await turnRow('turn-1', 'running', working) - const steered = await queuedDraft('sent into the turn between the presses') - await rig.sendNow(steered) - await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) - await rig.stop() - expect(stopEvents()).toHaveLength(2) - const handoff = await rig.handoffId(steered) - await withdraw(handoff) - await rig.settleAccepted(working, 'stopped') - await observationClock.observe(250) - // Handover without non-delivery proof must never requeue. - expect(await rig.drafts()).toEqual([]) - expect((await rig.submission(handoff))?.dispatchState).toBe('rejected') - }) - - it('naming no turn, names the turn running when it takes effect', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await turnRow('turn-1', 'running', working) - await rig.stop() - expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) - }) - - it('names a turn already over, as a late Stop from a phone does: writes nothing', async () => { - rig = await createQueuedMessageTestRig() - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) - const fields = { turnId: 'turn-already-over' } - const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), - ...fields - }) - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(stopEvents()).toEqual([]) - }) - - it('names a turn that ended while the next card is sent but shows no turn yet: writes nothing, and the card runs', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - await turnRow('turn-1', 'running', working) - const next = await queuedDraft('sent when turn-1 ends') - await rig.settleAccepted(working, 'working') - await turnRow('turn-1', 'interrupted') - await eventually(async () => expect((await rig.handoff(next))?.handedOverAt).toBeDefined()) - expect(journal().runningTurn()?.turnId ?? null).toBeNull() - const fields = { turnId: 'turn-1' } - const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), - ...fields - }) - expect(stopped).toMatchObject({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) - expect(stopEvents()).toEqual([]) - expect(rig.cancelTurn).not.toHaveBeenCalled() - expect(await rig.queuePause()).toBeNull() - expect((await rig.handoff(next))?.dispatchState).toBe('pending') - }) - - it("holds a card when it lands between the queue's pick and its claim", async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - const draftId = await queuedDraft('picked by the drain') - const open = journal() - const appendSubmission = open.appendSubmission.bind(open) - let injected = false - // Force a pause after selection to verify the claim transaction still holds the card. - vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { - if (consume?.yieldsToPause && consume.messageId === draftId && !injected) { - injected = true - await open.appendStopEvent({ reason: 'user-stop' }, input.fence) - } - return appendSubmission(input, consume) - }) - await rig.settleAccepted(working, 'working') - await eventually(() => expect(injected).toBe(true)) - await observationClock.observe(250) - expect(await rig.handoff(draftId)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index 3818cb3c0e85..91ca57e56934 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -70,7 +70,7 @@ async function stopWhileTheTurnOpens( publish: vi.fn(), now: () => 1 } - await performCancel(ctx, { clientOperationId: 'stop-1' }) + await performCancel(ctx, { clientOperationId: 'stop-1', endSession: () => {} }) return journal .snapshot() .items.flatMap((item) => @@ -79,11 +79,11 @@ async function stopWhileTheTurnOpens( } describe('a Stop that waited for a turn to open', () => { - it('keeps its note with that turn when the interrupt took it', async () => { + it('keeps the request before the turn that opens during interruption', async () => { expect(await stopWhileTheTurnOpens({ cancelled: true, turnId: 'turn-1' })).toEqual([ { - itemId: agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')), - turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(TURN) } + itemId: agentJournalItemKey(structuredAgentSessionStopNoteIdentity('stop-1')), + turnScope: AGENT_JOURNAL_THREAD_SCOPE } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 5bb031920bfd..338f5aa805d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -1,4 +1,4 @@ -// Withdrawn sends render their own feedback; another notice misattributes the old turn on legacy clients. +// Accepted Stop retains its request note even when child exit takes back the send. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -119,10 +119,10 @@ async function stopEndingTheChild(options: { } describe('a Stop that ends the child after its interrupt failed', () => { - it('leaves no row when the child end took back the send, with no turn running', async () => { + it('records Stop when the child end took back the send, with no turn running', async () => { expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: true })).toEqual({ cancelled: true, - rows: [] + rows: ['Cancellation requested.'] }) }) @@ -133,14 +133,14 @@ describe('a Stop that ends the child after its interrupt failed', () => { }) }) - it("leaves no row when the only other send in doubt was an earlier process's", async () => { + it("keeps Stop when the only other send in doubt was an earlier process's", async () => { expect( await stopEndingTheChild({ turnRunning: false, takesSendBack: true, earlierProcessSendInDoubt: true }) - ).toEqual({ cancelled: true, rows: [] }) + ).toEqual({ cancelled: true, rows: ['Cancellation requested.'] }) }) it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts index 9ada613e91fe..28cc70a9848c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts @@ -25,7 +25,7 @@ let serial = 0 let warned: ReturnType beforeEach(async () => { - rig = await createQueuedMessageTestRig() + rig = await createQueuedMessageTestRig({ restartable: true }) warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) }) @@ -51,6 +51,18 @@ function receipt(id: string) { return rig.store.readCommandReceipt(CALLER_SCOPE, id) } +it('does not treat completed submission history as a send in an empty reinitialization', async () => { + const id = await rig.workingSend() + await rig.settleAccepted(id, 'old') + await turnRow('turn-old', 'interrupted') + const child = rig.host.collaboratorsForTests().sessions.get(SESSION)!.child! + child.phase = 'starting' + expect(await rig.stop()).toMatchObject({ ok: true }) + expect( + (await rig.host.journalSnapshot(SESSION)).items.filter((item) => item.body.kind === 'status') + ).toEqual([]) +}) + function stopEvents(): { sequence: number; event: JournalStopEvent }[] { const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) if (!since.ok) { @@ -71,7 +83,10 @@ async function turnRow(turnId: string, state: 'running' | 'interrupted'): Promis startedAt: 1, ...(state === 'running' ? {} : { completedAt: 2 }) }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts deleted file mode 100644 index a7bfe2e1da45..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' - -let rig: QueuedMessageTestRig - -afterEach(() => rig.dispose()) - -const LATER: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'thread-1', - turnId: 'turn-later', - ordinal: 1000 -} - -const OPENED: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'thread-1', - turnId: 'turn-opened', - ordinal: 999 -} - -function journal() { - const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!open) { - throw new Error('expected the conversation open') - } - return open -} - -async function turnOpens(clientMessageId: string): Promise { - await journal().appendItem( - OPENED, - { - kind: 'turn', - turnId: 'turn-opened', - state: 'running', - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(clientMessageId) - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -function openedTurn() { - return journal() - .snapshot() - .items.map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'turn-opened') -} - -function nothingRuns(): boolean { - return !isStructuredAgentSessionMainAgentWorking( - journal().runningTurn()?.turnId ?? null, - journal().submissions() - ) -} - -async function windDown(): Promise { - await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) -} - -describe('a Codex Stop whose answered turn did not open in time', () => { - // Codex answered into a turn that did not open before the Stop wait expired. - async function stopOfAnsweredSend(): Promise { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) - return sent - } - - it('ends the child, so the turn can never run', async () => { - await stopOfAnsweredSend() - - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(rig.closeSession).toHaveBeenCalled() - expect(openedTurn()).toBeUndefined() - expect(nothingRuns()).toBe(true) - }) - - it('reads a turn that opened before the child end as interrupted by the Stop', async () => { - const sent = await stopOfAnsweredSend() - rig.closeSession.mockImplementationOnce(async () => { - await turnOpens(sent) - return true - }) - - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(nothingRuns()).toBe(true) - }) -}) - -describe('a Codex Stop that could not reach a turn still able to open, whose kill failed', () => { - it('says the Stop is unconfirmed, never that no turn was running', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - - const rows = journal() - .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.failure?.kind] : [])) - expect(rows).toEqual(['cancelUnconfirmed']) - }) -}) - -describe('a Claude Stop pressed before its send echoed', () => { - it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { - rig = await createQueuedMessageTestRig({ stopEndsSession: true }) - const sent = await rig.workingSend() - rig.closeSession.mockImplementationOnce(async () => { - await turnOpens(sent) - return true - }) - - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - await windDown() - - expect(rig.closeSession).toHaveBeenCalled() - expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(nothingRuns()).toBe(true) - }) -}) - -describe("a person's close pressed before its send's turn showed", () => { - it('reads the turn its child end cut as interrupted by the person', async () => { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - rig.closeSession.mockImplementationOnce(async () => { - await turnOpens(sent) - return true - }) - - await rig.host.close(HOST_TEST_SESSION, 'user-close') - - const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) - const turn = items - .map((item) => readAgentJournalTurn(item.body)) - .find((entry) => entry?.turnId === 'turn-opened') - expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - }) - - it('binds no turn that ends on its own after a close the conversation outlived', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) - expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - const fence = rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - - await journal().appendItem( - LATER, - { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, - { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal().appendItem( - LATER, - { - kind: 'turn', - turnId: 'turn-later', - state: 'interrupted', - startedAt: Date.now(), - completedAt: Date.now() + 5 - }, - { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const later = journal() - .snapshot() - .items.map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'turn-later') - expect(later).toMatchObject({ state: 'interrupted' }) - expect(later).not.toHaveProperty('outcome') - }) - - // Retrying an unproven close must bind the turn that the retry cuts. - it('binds the turn a repeated close cuts after a first close that came back unproven', async () => { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - const close = () => rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) - rig.closeSession.mockResolvedValueOnce(false) - await expect(close()).rejects.toThrow() - rig.closeSession.mockImplementationOnce(async () => { - await turnOpens(sent) - return true - }) - - await close() - - expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index fcbdb09477db..1003e2a7b2f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -20,6 +20,7 @@ export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean stoppedAt: number stopNote: AgentJournalItemIdentity + stopNoteWrite?: Promise /** End settlement regardless of exit proof; failedOn identifies work still running. */ settled?: (failedOn?: JournalStopFailedOn) => void } @@ -38,12 +39,14 @@ export async function endStoppedStructuredAgentSession( await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) } await stopChild() + await windDown.stopNoteWrite?.catch(onError) } catch (error) { onError(error) failedOn = structuredAgentSessionFailedStopMark( contextStructuredAgentSessionCurrentWork(ctx), ctx.journal ) + await windDown.stopNoteWrite?.catch(onError) await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) } finally { windDown.settled?.(failedOn) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history.test.ts index ba60a73e6009..0ba2f5c5b815 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history.test.ts @@ -106,10 +106,12 @@ describe('a Stop withdraws what the agent never got, and the next send delivers expect(dispatched()).toEqual([carrying(['first', 'second'], 'what did I send?'), 'after']) expect(journal().stoppedHistoryCarried().get(carrier)).toEqual([first, second]) + expect(await transcript()).toEqual([ 'first (stopped)', 'second (stopped)', 'Stopped manually', + 'Cancellation requested.', 'what did I send?', 'after' ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts deleted file mode 100644 index d0fb57d620a7..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary -} from '../../../shared/agent-session-wire' -import { - isStructuredAgentSessionStopNote, - structuredAgentSessionStopNoteIdentity -} from './structured-agent-session-command-turn' -import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - QUEUED_RIG_CALLER, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' - -let rig: QueuedMessageTestRig - -afterEach(() => rig.dispose()) - -function journal() { - const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!open) { - throw new Error('expected the conversation open') - } - return open -} - -function fence(): number { - return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 -} - -function turnIdentity(turnId: string): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 } -} - -async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { - await journal().appendItem( - turnIdentity(turnId), - { - kind: 'turn', - turnId, - startedAt: Date.now(), - userItemId: agentJournalSubmissionKey(clientMessageId), - ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) - }, - { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -function watchStatus(): () => AgentSessionStatusSummary | undefined { - const events: AgentSessionStatusEvent[] = [] - rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) - return () => { - for (const event of events.toReversed()) { - if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { - return event.session - } - if (event.type === 'snapshot') { - return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) - } - } - return undefined - } -} - -function namedStop(turnId: string) { - return rig.host.cancel(QUEUED_RIG_CALLER, { - envelope: rig.envelope({ turnId }, 'agentSession.cancel', hostTestOperationId()), - turnId - }) -} - -function stopAnswers(): (string | undefined)[] { - return journal() - .snapshot() - .items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) - .map((item) => (item.body.kind === 'status' ? (item.body.failure?.kind ?? 'took') : undefined)) -} - -async function runningTurn(options: { stopEndsSession?: true } = {}) { - rig = await createQueuedMessageTestRig(options) - const sent = await rig.workingSend() - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - const status = watchStatus() - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - return { sent, status } -} - -describe("a person's Stop reads Stopping until the work it stopped ends", () => { - it('reads Stopping once the Stop takes effect, and clears when the turn ends', async () => { - const { sent, status } = await runningTurn() - - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) - - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - expect(status()).not.toHaveProperty('stopping') - }) - - it('reads Stopping before the agent answers the interrupt', async () => { - const { status } = await runningTurn() - let answer: (outcome: AgentSessionCancelOutcome) => void = () => undefined - rig.cancelTurn.mockImplementationOnce( - () => new Promise((resolve) => (answer = resolve)) - ) - - const stopped = rig.stop() - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - - answer({ cancelled: true }) - expect(await stopped).toMatchObject({ ok: true }) - expect(status()).toMatchObject({ stopping: true }) - }) - - const refused = async (): Promise => ({ cancelled: false }) - const unconfirmed = async (): Promise => { - throw new Error('the interrupt request timed out') - } - it.each([ - ['refused', 'names no turn', refused], - ['left unconfirmed', 'names no turn', unconfirmed], - ['left unconfirmed', 'names the turn', unconfirmed] - ])( - 'keeps Stopping until the turn ends after the agent %s a Stop that %s', - async (_, naming, cancel) => { - const { sent, status } = await runningTurn() - rig.cancelTurn.mockImplementationOnce(cancel) - if (cancel === unconfirmed) { - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - } - - const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() - expect(await stopped).toMatchObject({ ok: true }) - - // Even a no-op answer leaves the personal Stop eligible for escalation. - await eventually(() => expect(stopAnswers()).toHaveLength(1)) - expect(stopAnswers()[0]).not.toBe('took') - expect(status()).toMatchObject({ status: 'working', stopping: true }) - - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - expect(status()).not.toHaveProperty('stopping') - } - ) - - it.each([ - [ - 'refuses', - async (): Promise => ({ cancelled: false, refusal: {} }) - ], - ['never answers', unconfirmed] - ])( - 'ends the agent when it %s a Stop naming the running turn, so Stopping ends with the turn', - async (_, cancel) => { - const { status } = await runningTurn() - rig.cancelTurn.mockImplementationOnce(cancel) - - expect(await namedStop('turn-1')).toMatchObject({ ok: true }) - - await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) - await eventually(() => expect(status()?.status).not.toBe('working')) - expect(status()).not.toHaveProperty('stopping') - } - ) - - it('keeps Stopping through a repeat press of a refused Stop, which writes no second event', async () => { - const { status } = await runningTurn() - rig.cancelTurn.mockImplementationOnce(async () => ({ cancelled: false })) - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) - const refusedStop = journal().stopMarks.latest() - - expect(await rig.stop()).toMatchObject({ ok: true }) - - expect(journal().stopMarks.latest()).toEqual(refusedStop) - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - }) - - it.each(['names its turn', 'names no turn'])( - 'reads a send made after a Stop that %s as Working before its turn opens', - async (naming) => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - const status = watchStatus() - if (naming === 'names its turn') { - await rig.settleAccepted(stopped, 'stopped') - await turn('turn-1', stopped, 'running') - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(await namedStop('turn-1')).toMatchObject({ ok: true }) - } else { - rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-1' }) - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(stopped, 'stopped') - await turn('turn-1', stopped, 'running') - } - await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) - await turn('turn-1', stopped, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - - await rig.workingSend() - - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - } - ) - - it('never marks a turn that opened after the stopped one ended', async () => { - const { sent, status } = await runningTurn() - expect(await rig.stop()).toMatchObject({ ok: true }) - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - - const next = await rig.workingSend() - await rig.settleAccepted(next, 'next') - await turn('turn-2', next, 'running') - - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - }) -}) - -describe('a Stop that failed before the turn it meant to stop opened', () => { - function turnEnd(turnId: string) { - return journal() - .snapshot() - .items.map((item) => item.body) - .find((body) => body.kind === 'turn' && body.turnId === turnId) - } - - async function failedBeforeTheTurn(options: { stopEndsSession?: true; restartable?: true } = {}) { - rig = await createQueuedMessageTestRig(options) - const sent = await rig.workingSend() - const status = watchStatus() - if (!options.stopEndsSession) { - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) - } - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) - expect(journal().runningTurn()?.turnId ?? null).toBeNull() - return { sent, status } - } - - it.each([ - ['Codex, refused, then its kill failed', {}], - ['Claude-like, whose kill failed before the echo', { stopEndsSession: true as const }] - ])( - 'reads Stopping through the turn that then opens, whose own end stays its own: %s', - async (_, options) => { - const { sent, status } = await failedBeforeTheTurn(options) - await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) - - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - await eventually(() => expect(journal().runningTurn()?.turnId ?? null).toBe('turn-1')) - expect(status()).toMatchObject({ status: 'working', stopping: true }) - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - - expect(status()).not.toHaveProperty('stopping') - expect(turnEnd('turn-1')).toMatchObject({ state: 'interrupted' }) - expect(turnEnd('turn-1')).not.toHaveProperty('outcome') - } - ) - - it("never reads Stopping on a send's turn handed over after it", async () => { - const { sent, status } = await failedBeforeTheTurn({ restartable: true }) - await rig.settleRejected(sent, 'no turn for this one') - await eventually(() => expect(status()?.status).not.toBe('working')) - - const later = await rig.workingSend() - await rig.settleAccepted(later, 'later') - await turn('turn-2', later, 'running') - - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - }) -}) - -describe("a Stop's settle edge", () => { - // Row-free settlement must publish status and wake delivery without counting as activity. - it('republishes Stopping without counting as activity', async () => { - rig = await createQueuedMessageTestRig() - await rig.workingSend() - const status = watchStatus() - await journal().appendStopEvent({ reason: 'user-stop' }, fence()) - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - const sessions = rig.host.collaboratorsForTests().sessions - const touch = vi.spyOn(sessions, 'touch') - - const settle = journal().stopMarks.beginSettle() - await eventually(() => expect(status()).toMatchObject({ stopping: true })) - journal().stopMarks.settled(settle) - await eventually(() => expect(status()).not.toHaveProperty('stopping')) - - expect(touch).not.toHaveBeenCalled() - }) -}) - -describe('a Stop pressed before its send opened a turn', () => { - async function stopAsTheTurnOpens(answer: AgentSessionCancelOutcome, killFails?: true) { - rig = await createQueuedMessageTestRig() - if (killFails) { - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - } - const sent = await rig.workingSend() - const status = watchStatus() - rig.cancelTurn.mockImplementationOnce(async () => { - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - return answer - }) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - return { sent, status } - } - - it('ends the turn its interrupt took at the settle, and Stopping with it', async () => { - const { status } = await stopAsTheTurnOpens({ cancelled: true, turnId: 'turn-1' }) - - expect(journal().runningTurn()?.turnId ?? null).toBeNull() - await eventually(() => expect(status()?.status).toBe('idle')) - expect(status()).not.toHaveProperty('stopping') - }) - - it('holds Stopping through the turn a Stop that failed could not stop, until that turn ends', async () => { - const { sent, status } = await stopAsTheTurnOpens({ cancelled: false, refusal: {} }, true) - - await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - expect(status()).not.toHaveProperty('stopping') - const ended = journal() - .snapshot() - .items.map((item) => item.body) - .find((body) => body.kind === 'turn' && body.turnId === 'turn-1') - expect(ended).toMatchObject({ state: 'interrupted' }) - expect(ended).not.toHaveProperty('outcome') - }) - - it('reads Stopping while the Stop settles, and Working once it settles having stopped nothing', async () => { - rig = await createQueuedMessageTestRig() - const sent = await rig.workingSend() - const status = watchStatus() - const answer = Promise.withResolvers() - rig.cancelTurn.mockImplementationOnce(() => answer.promise) - - const stopped = rig.stop() - await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) - answer.resolve({ cancelled: false }) - expect(await stopped).toMatchObject({ ok: true }) - - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - await eventually(() => expect(journal().runningTurn()?.turnId ?? null).toBe('turn-1')) - expect(status()).not.toHaveProperty('stopping') - }) - - it('reads a send made after the Stop as Working', async () => { - rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() - const status = watchStatus() - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(stopped, 'stopped') - await turn('turn-1', stopped, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - - const later = await rig.workingSend() - await rig.settleAccepted(later, 'later') - await turn('turn-2', later, 'running') - - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - }) -}) - -describe('a Stop whose provider ends its session', () => { - it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { - rig = await createQueuedMessageTestRig({ stopEndsSession: true }) - const sent = await rig.workingSend() - const status = watchStatus() - const kill = Promise.withResolvers() - rig.closeSession.mockImplementationOnce(() => kill.promise) - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) - - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - kill.reject(new Error('the kill timed out')) - - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - expect(status()).toMatchObject({ status: 'working', stopping: true }) - }) - - it("says the Stop went unconfirmed once the child's end fails, and that it took once the next Stop's joined close proves the exit", async () => { - const { status } = await runningTurn({ stopEndsSession: true }) - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - - expect(await rig.stop()).toMatchObject({ ok: true }) - - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - expect(status()).toMatchObject({ status: 'working', stopping: true }) - - // Hold the retried close to inspect status while it runs. - const retried = Promise.withResolvers() - rig.closeSession.mockImplementationOnce(() => retried.promise) - const retry = rig.stop() - await eventually(() => expect(rig.closeSession).toHaveBeenCalledTimes(2)) - expect(status()).toMatchObject({ status: 'working', stopping: true }) - retried.resolve(true) - expect(await retry).toMatchObject({ ok: true }) - expect(stopAnswers()).toEqual(['took']) - }) - - it('keeps the note unconfirmed when the next Stop joins a close that fails again', async () => { - const { status } = await runningTurn({ stopEndsSession: true }) - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) - expect(await rig.stop()).toMatchObject({ ok: true }) - - expect(rig.closeSession).toHaveBeenCalledTimes(2) - expect(stopAnswers()).toEqual(['cancelUnconfirmed']) - expect(status()).toMatchObject({ status: 'working', stopping: true }) - }) - - // Close state dies with the host; restart must prove the old owner dead to settle the turn. - async function crashAfterUnconfirmedStop(): Promise { - await runningTurn({ stopEndsSession: true }) - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - rig.crashRestartHostProcess() - } - - function turnOneState(): string | undefined { - return journal() - .snapshot() - .items.map((item) => (item.body.kind === 'turn' ? item.body : undefined)) - .find((turn) => turn?.turnId === 'turn-1')?.state - } - - async function proveOldOwnerGone(): Promise { - const record = rig.store.getRecord(HOST_TEST_SESSION)! - await rig.store.evictProvenDeadOwner({ - sessionId: HOST_TEST_SESSION, - expectedFence: record.lease.runtimeFence, - probe: { outcome: 'pid-absent' }, - now: Date.now() - }) - } - - it("says the Stop took once the new host proves the old child's exit after a crash", async () => { - await crashAfterUnconfirmedStop() - await proveOldOwnerGone() - await rig.queuePause() - - expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ - turn: 'interrupted', - notes: ['took'] - }) - }) - - it('keeps the note unconfirmed while the old exit stays unverifiable, then says it took once proven', async () => { - await crashAfterUnconfirmedStop() - await rig.queuePause() - expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ - turn: 'unverifiable', - notes: ['cancelUnconfirmed'] - }) - - await proveOldOwnerGone() - await eventually(() => - expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ - turn: 'interrupted', - notes: ['took'] - }) - ) - }) - - it('moves a note a Stop wrote before its turn showed onto that turn, which then says the Stop took', async () => { - rig = await createQueuedMessageTestRig({ stopEndsSession: true }) - const sent = await rig.workingSend() - const kill = Promise.withResolvers() - rig.closeSession.mockImplementationOnce(() => kill.promise) - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) - await rig.settleAccepted(sent, 'sent') - await turn('turn-1', sent, 'running') - kill.reject(new Error('the kill timed out')) - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(stopAnswers()).toEqual(['cancelUnconfirmed']) - - expect(await rig.stop()).toMatchObject({ ok: true }) - - await eventually(() => expect(stopAnswers()).toEqual(['took'])) - const turnRecord = journal() - .snapshot() - .items.find((item) => item.body.kind === 'turn' && item.body.turnId === 'turn-1') - const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - const notes = page.ok - ? page.page.items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) - : [] - expect(notes.map((item) => [item.itemId, item.turnScope])).toEqual([ - [ - agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')), - { kind: 'turn', turnItemId: turnRecord?.itemId } - ] - ]) - }) - - it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { - await runningTurn({ stopEndsSession: true }) - rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) - expect(await rig.stop()).toMatchObject({ ok: true }) - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - - await rig.host.handleAdapterEvent({ - type: 'ended', - sessionId: HOST_TEST_SESSION, - reason: 'claude session closed', - cause: 'requested-close', - fence: fence(), - acquisitionGeneration: 'generation-1', - observedAt: Date.now() - }) - - await eventually(() => - expect( - rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null - ).toBeNull() - ) - expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ - turn: 'interrupted', - notes: ['took'] - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts index 689b328929a7..ad64f5fa85e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts @@ -1,8 +1,13 @@ // Key Stop notes by captured turn so repeated Stops revise one row, even after the turn ends. -import type { AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalStatusItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' export const STOP_NOTE_CANCELLATION_REQUESTED = 'Cancellation requested.' @@ -48,3 +53,50 @@ export function structuredAgentSessionNamedTurnScope( .items.findLast((item) => readAgentJournalTurn(item.body)?.turnId === turnId) return turn ? { kind: 'turn', turnItemId: turn.itemId } : null } + +/** The turn the provider says the interrupt took, even one that opened while the cancel waited for + * it: the note is that turn's, never a conversation row read before the wait. */ +export function structuredAgentSessionStopNoteScope( + journal: Pick, + stoppedTurn: string | undefined, + fallback: AgentJournalTurnScope +): AgentJournalTurnScope { + return ( + (stoppedTurn !== undefined + ? structuredAgentSessionNamedTurnScope(journal, stoppedTurn) + : null) ?? fallback + ) +} + +export function writeStructuredAgentSessionStopNote( + ctx: { + journal: AgentSessionJournal + fence: number + logger: StructuredAgentSessionLogger + sessionId: string + }, + input: { + identity: ReturnType + body: AgentJournalStatusItem + stoppedTurn?: string + fallbackScope: AgentJournalTurnScope + } +): Promise { + return ctx.journal + .appendItem(input.identity, input.body, { + fence: ctx.fence, + turnScope: structuredAgentSessionStopNoteScope( + ctx.journal, + input.stoppedTurn, + input.fallbackScope + ) + }) + .then(() => undefined) + .catch((error: unknown) => + ctx.logger.warn('writing the Stop note failed', { + scope: 'chat-stop-note', + sessionId: ctx.sessionId, + error + }) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index b85f071ac3a6..7e9bbae575db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -2,7 +2,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem, - AgentJournalSubmission + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -20,13 +20,13 @@ import { STOP_NOTE_CANCELLATION_REQUESTED, structuredAgentSessionNamedTurnScope, structuredAgentSessionStopNamesTurnNotLive, - structuredAgentSessionStoppedTurnId + structuredAgentSessionStoppedTurnId, + writeStructuredAgentSessionStopNote } from './structured-agent-session-turn-stop-notes' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' -import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import { settleTakenStop } from './structured-agent-session-taken-stop-settle' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -50,18 +50,6 @@ function stillRunsStoppedTurn( return stoppedTurnId === null || work.activeTurnId() === stoppedTurnId } -function tookBackEverySend( - ctx: Pick, - inFlight: readonly AgentJournalSubmission[] -): boolean { - const rejected = new Set( - ctx.journal - .submissions() - .flatMap((entry) => (entry.dispatchState === 'rejected' ? [entry.clientMessageId] : [])) - ) - return inFlight.length > 0 && inFlight.every((entry) => rejected.has(entry.clientMessageId)) -} - /** A refusal that may still open a turn is unconfirmed, never proof that nothing ran. */ function stopRefusedNote( ctx: Pick, @@ -93,8 +81,10 @@ export async function performCancel( return answerCancelOfSettledPrompt(ctx, { ...input, prompt: input.prompt }, validated) } } - // Turnless Stop binds work ending during its settlement. - const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null + // An accepted chat Stop owns every turn its captured child ends, whatever its event names. + const settle = input.opensSettle + ? ctx.journal.stopMarks.beginSettle(Boolean(input.acceptedStop && input.endSession)) + : null const binding: StopSettleBinding = {} const close = (failedOn?: JournalStopFailedOn): void => ctx.journal.stopMarks.settled(settle, binding.turnId, binding.failedOn ?? failedOn) @@ -107,10 +97,19 @@ export async function performCancel( } } +/** A chat Stop's accepted target and request feedback survive later dispatch settlement. */ +export type AcceptedChatStop = { + turnId: string | null + turnScope: AgentJournalTurnScope + previouslyStopped: boolean + requestNoteWrite?: Promise +} + type PerformCancelInput = { clientOperationId: string /** Absent targets all in-flight work; present targets only this current turn. */ turnId?: string + acceptedStop?: AcceptedChatStop prompt?: { itemId: string; expectedRevision: number } /** End the child when a command/turn cannot be interrupted. */ stopChild?: () => Promise @@ -142,12 +141,17 @@ async function cancelAndNote( const work = () => contextStructuredAgentSessionCurrentWork(ctx) // Capture the note’s turn before cancel can settle it. const turnScope = + input.acceptedStop?.turnScope ?? (input.turnId !== undefined ? structuredAgentSessionNamedTurnScope(ctx.journal, input.turnId) - : null) ?? work().turnScope() + : null) ?? + work().turnScope() // Commands end only through provider or child exit; dead-generation settlement supplies their verdict. const liveTurnId = work().activeTurnId() - const stoppedTurnId = structuredAgentSessionStoppedTurnId(work(), input.turnId) + // Read with the live turn, before the cancel settles it: the note is keyed by this turn. + const stoppedTurnId = input.acceptedStop + ? input.acceptedStop.turnId + : structuredAgentSessionStoppedTurnId(work(), input.turnId) const namesTurnNotLive = structuredAgentSessionStopNamesTurnNotLive(input.turnId, liveTurnId) const runningCommand = input.stopChild !== undefined && @@ -155,18 +159,36 @@ async function cancelAndNote( isStructuredAgentSessionCommandTurnId(liveTurnId) && !namesTurnNotLive const stoppedBefore = - runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) - // Capture before session-ending Stop discards the child. - const endsSession = - input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + runningCommand && + (input.acceptedStop?.previouslyStopped ?? + structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId)) + // One rule for every agent: a chat's Stop ends its session, background work included; the next + // send resumes the conversation. A task row's Stop has its own targeted path. + const endsSession = input.endSession !== undefined const stoppedAt = Date.now() - // Capture sends before cancel can settle them. - const sentBeforeStop = ctx.journal.submissions().filter(sendStopCanTakeBack) + const noteIdentity = structuredAgentSessionStopNoteIdentity( + stoppedTurnId ?? input.clientOperationId + ) + let stoppedTurn: string | undefined = stoppedTurnId ?? undefined + let noteWritten = input.acceptedStop?.requestNoteWrite !== undefined + const writeNote = (body: AgentJournalStatusItem): Promise => { + noteWritten = true + // A child end this Stop caused moved the fence past `ctx.fence`. + const fence = Math.max(ctx.fence, input.noteFence?.() ?? ctx.fence) + return writeStructuredAgentSessionStopNote( + { journal: ctx.journal, fence, logger: ctx.logger, sessionId: ctx.sessionId }, + { identity: noteIdentity, body, stoppedTurn, fallbackScope: turnScope } + ) + } + const requestNoteWrite = input.acceptedStop + ? input.acceptedStop.requestNoteWrite + : endsSession && !namesTurnNotLive + ? writeNote(note) + : undefined // undefined means cancel threw and its effect is unknown. let taken: boolean | undefined let interruptFailed = false let refusal: AgentSessionCancelOutcome['refusal'] - let stoppedTurn: string | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const outcome: AgentSessionCancelOutcome = stoppedBefore @@ -175,6 +197,7 @@ async function cancelAndNote( sessionId: ctx.sessionId, ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), fence: ctx.fence, + ...(endsSession ? { stoppedAt } : {}), // Validate against the journal the client used to name its turn. resolveLiveTurnId: () => work().activeTurnId(), ...(dispatchStatus ? { dispatchStatus } : {}), @@ -189,7 +212,7 @@ async function cancelAndNote( if (!cancelled && !isMainAgentWorking(ctx) && (await input.withdrewQueued) === true) { // Withdrawing all queued work confirms Stop regardless of provider no-op wording. cancelled = true - note = null + note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if (!cancelled && input.prompt) { note = null } else if (!cancelled && input.turnId === undefined) { @@ -205,26 +228,30 @@ async function cancelAndNote( ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) } } - const noteIdentity = structuredAgentSessionStopNoteIdentity( - stoppedTurn ?? stoppedTurnId ?? input.clientOperationId - ) - // Declined old-turn Stops retain the session; any other interrupt may affect a follow-up before it opens. - if (endsSession && (!namesTurnNotLive || taken !== false)) { + // Accepted chat Stops retain runtime closure; other named cancellations keep their live-turn scope. + if (endsSession && (input.acceptedStop || !namesTurnNotLive || taken !== false)) { // Accepted interrupts warrant wind-down even before their turn echo arrives. binding.closedByWindDown = true + // Child exit confirms Stop, superseding interrupt uncertainty. + const ended: AgentJournalStatusItem | null = + note === null || (input.acceptedStop && !requestNoteWrite) + ? null + : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + const stopNoteWrite = ended !== null ? (requestNoteWrite ?? writeNote(ended)) : undefined input.endSession?.({ waitsForProvider: taken === true, stoppedAt, stopNote: noteIdentity, + stopNoteWrite, settled: closeSettle }) cancelled = true - // Child exit confirms Stop, superseding interrupt uncertainty. - if (note !== null) { - note = { kind: 'status', text: 'Cancellation requested.' } - } + note = ended } else if (runningCommand && !cancelled) { + const noteWrite = + requestNoteWrite ?? writeNote({ kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }) await input.stopChild?.() + await noteWrite cancelled = true note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if ( @@ -234,6 +261,8 @@ async function cancelAndNote( stillRunsStoppedTurn(ctx, stoppedTurnId) ) { let ended: boolean + const noteWrite = + requestNoteWrite ?? writeNote({ kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }) try { await input.stopChild() ended = true @@ -244,14 +273,11 @@ async function cancelAndNote( } if (ended) { cancelled = true - // Withdrawn sends render their own feedback; another notice misattributes the previous turn on old clients. - note = - stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) - ? null - : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if (taken !== undefined) { note = stopRefusedNote(ctx, refusal) } + await noteWrite } else if (!cancelled && taken === false && input.turnId !== undefined) { note = null } @@ -262,30 +288,16 @@ async function cancelAndNote( binding.failedOn = structuredAgentSessionFailedStopMark(work(), ctx.journal) } if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown) { - const opened = await settleTakenStop(ctx, stoppedTurn) - if (!opened && stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop)) { - note = null - } + await settleTakenStop(ctx, stoppedTurn) } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } if (note === null) { return { ok: true, value } } - // Prefer the provider’s turn opened during cancel over the scope captured before waiting. - const noteScope = - (stoppedTurn !== undefined - ? structuredAgentSessionNamedTurnScope(ctx.journal, stoppedTurn) - : null) ?? turnScope - // Best-effort notes cannot change the accepted Stop. - const fence = Math.max(ctx.fence, input.noteFence?.() ?? ctx.fence) - await ctx.journal - .appendItem(noteIdentity, note, { fence, turnScope: noteScope }) - .catch((error: unknown) => - ctx.logger.warn("writing a Stop's note failed", { - scope: 'stop-note', - sessionId: ctx.sessionId, - error - }) - ) + await (!noteWritten || note.text !== STOP_NOTE_CANCELLATION_REQUESTED + ? writeNote(note) + : binding.closedByWindDown + ? undefined + : requestNoteWrite) return { ok: true, value } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts deleted file mode 100644 index b62659d3470c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { AgentHookServer } from '../../agent-hooks/server' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' -import { runTargetedCancel } from './structured-agent-session-targeted-cancel' -import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { testEventSinkLogging } from './structured-agent-session-logger-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -let root: string | null = null -const journals = createTrackedJournalOpener() - -afterEach(async () => { - await journals.closeAll() - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -describe('performCancel', () => { - it('acknowledges only the request and leaves the running lifecycle row intact', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const lifecycleIdentity = { - provider: 'legacy' as const, - agent: 'codex' as const, - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' - } - await journal.appendItem( - lifecycleIdentity, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const cancelTurn = vi.fn(async () => ({ cancelled: true })) - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - - const result = await performCancel(ctx, { - clientOperationId: 'cancel-1', - turnId: 'turn-1' - }) - - expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - expect(journal.snapshot().items.map((item) => item.body)).toEqual([ - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { kind: 'status', text: 'Cancellation requested.' } - ]) - }) - - it('hands the adapter a live-turn read of the published journal', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-live-turn-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const lifecycleIdentity = { - provider: 'legacy' as const, - agent: 'codex' as const, - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' - } - await journal.appendItem( - lifecycleIdentity, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - let resolveLiveTurnId: (() => string | null) | undefined - const cancelTurn = vi.fn( - async (input: Parameters[0]) => { - resolveLiveTurnId = input.resolveLiveTurnId - return { cancelled: true } - } - ) - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - - await performCancel(ctx, { clientOperationId: 'cancel-live-1', turnId: 'turn-1' }) - - expect(resolveLiveTurnId?.()).toBe('turn-1') - // The guard must reread after the turn ends rather than use captured state. - await journal.appendItem( - lifecycleIdentity, - { - kind: 'status', - text: 'Done.', - turnLifecycle: { turnId: 'turn-1', state: 'completed' } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - expect(resolveLiveTurnId?.()).toBeNull() - }) - - it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - await journal.appendItem( - { - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' - }, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { - cancelTurn: vi.fn(async () => ({ cancelled: false })) - } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - - const result = await performCancel(ctx, { - clientOperationId: 'cancel-unconfirmed-1', - turnId: 'turn-1' - }) - - expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) - expect(journal.snapshot().items.map((item) => item.body)).toEqual([ - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - } - ]) - }) - - it('stops background tasks without interrupting the foreground turn or writing a row', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const cancelTurn = vi.fn(async () => ({ cancelled: true })) - const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - - const result = await runTargetedCancel(acceptedStop(ctx), { - clientOperationId: 'cancel-background-tasks', - turnId: 'background-tasks', - scope: 'background-tasks', - childWork: () => [liveTask('task-1'), liveTask('task-2', { stoppable: false })] - }) - - expect(result).toEqual({ - ok: true, - value: { turnId: 'background-tasks', cancelled: true } - }) - expect(stopBackgroundTasks).toHaveBeenCalledWith({ - sessionId: 'session-1', - fence: 1, - taskIds: ['task-1'], - acknowledge: expect.any(Function) - }) - expect(cancelTurn).not.toHaveBeenCalled() - expect(journal.snapshot().items).toEqual([]) - }) - - it('routes one background task id without interrupting the foreground turn or writing a row', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const cancelTurn = vi.fn(async () => ({ cancelled: true })) - const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - - const result = await runTargetedCancel(acceptedStop(ctx), { - clientOperationId: 'cancel-background-task-2', - turnId: 'background-tasks', - scope: 'background-tasks', - taskId: 'task-2', - childWork: () => [liveTask('task-1'), liveTask('task-2')] - }) - - expect(result).toEqual({ - ok: true, - value: { turnId: 'background-tasks', cancelled: true } - }) - expect(stopBackgroundTasks).toHaveBeenCalledWith({ - sessionId: 'session-1', - fence: 1, - taskIds: ['task-2'], - acknowledge: expect.any(Function) - }) - expect(cancelTurn).not.toHaveBeenCalled() - expect(journal.snapshot().items).toEqual([]) - }) - - it('omits an earlier listed Stop when its same-ID lifetime restarts during acceptance', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-background-stop-lifetime-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const owner = new StructuredAgentSessionStatusOwnership(() => undefined) - const live = (restart = false): AgentChildWorkEvidence => ({ - type: 'live', - observedAt: 1, - child: { - handle: { idKind: 'task_id', id: 'helper', runId: 'spawn-a' }, - kind: 'agent', - residency: 'background', - state: 'working', - stoppable: true - }, - ...(restart ? { restart: true as const } : {}) - }) - owner.publishChildWork('session-1', [live()], 'claude') - let acceptStop: (() => void) | undefined - const accepting = new Promise((resolve) => { - acceptStop = resolve - }) - const stopBackgroundTasks = vi.fn( - async (request: { acknowledge: (value: { taskId: string }) => void }) => { - request.acknowledge({ taskId: 'helper' }) - return { cancelled: true } - } - ) - const ctx = acceptedStop({ - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This path calls only stopBackgroundTasks on the test adapter. - adapter: { stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - }) - ctx.operationReceipt!.commitAlone = () => accepting - const stopping = runTargetedCancel(ctx, { - clientOperationId: 'stop-earlier-lifetime', - scope: 'background-tasks', - childWork: () => [ - liveTask('helper', { invocation: { invocationId: 'spawn-a', generation: 1 } }) - ], - captureStops: (targets) => - owner.captureChildWorkStops( - 'session-1', - targets.map((target) => target.providerId) - ), - acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude') - }) - expect(stopBackgroundTasks).not.toHaveBeenCalled() - owner.publishChildWork('session-1', [live(true)], 'claude') - acceptStop?.() - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(stopBackgroundTasks).not.toHaveBeenCalled() - expect(owner.childWorkOpen('session-1')).toBe(true) - }) - - it('does not close the agent when a refused helper appears during a listed Stop all', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-background-stop-refused-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const server = new AgentHookServer() - let refuse = false - const owner = new StructuredAgentSessionStatusOwnership(() => ({ - publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), - forget: (subject) => server.dropStructuredStatus(subject), - publishChildWork: (subject, evidence, provider) => { - if (!refuse) { - server.ingestStructuredChildWork(subject, evidence, provider) - } - }, - readChildWork: (subject) => server.getStructuredChildWorkViews(subject), - readChildWorkHandles: (subject) => server.getStructuredChildWorkHandles(subject) - })) - const location: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - } - const summary: AgentSessionStatusSummary = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - agent: 'claude', - status: 'idle', - latestPrompt: 'fixture', - updatedAt: 1 - } - const live = (runId: string): AgentChildWorkEvidence => ({ - type: 'live', - observedAt: 1, - child: { - handle: { idKind: 'task_id', id: 'helper', runId }, - kind: 'agent', - residency: 'background', - state: 'working', - stoppable: true - } - }) - owner.publish(summary, location) - owner.publishChildWork('session-1', [live('spawn-a')], 'claude') - expect(owner.readChildWork('session-1')).toHaveLength(1) - expect(owner.childWorkIncomplete('session-1')).toBe(false) - - let releaseStop: (() => void) | undefined - const waiting = new Promise((resolve) => { - releaseStop = resolve - }) - let stopStarted: (() => void) | undefined - const started = new Promise((resolve) => { - stopStarted = resolve - }) - const stopBackgroundTasks = vi.fn( - async (request: { acknowledge: (value: { taskId: string }) => void }) => { - stopStarted?.() - await waiting - request.acknowledge({ taskId: 'helper' }) - return { cancelled: true } - } - ) - const stopAgent = vi.fn(async () => {}) - const ctx = acceptedStop({ - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'claude', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This path calls only stopBackgroundTasks on the test adapter. - adapter: { stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - }) - const stopping = runTargetedCancel(ctx, { - clientOperationId: 'stop-all-before-refusal', - scope: 'background-tasks', - childWork: () => owner.readChildWork('session-1'), - captureStops: (targets) => - owner.captureChildWorkStops( - 'session-1', - targets.map((target) => target.providerId) - ), - acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude'), - unlisted: { present: () => owner.childWorkIncomplete('session-1'), stop: stopAgent } - }) - await started - refuse = true - owner.publishChildWork('session-1', [live('spawn-b')], 'claude') - refuse = false - releaseStop?.() - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(owner.childWorkIncomplete('session-1')).toBe(true) - expect(owner.childWorkOpen('session-1')).toBe(true) - expect(stopAgent).not.toHaveBeenCalled() - }) - - async function backgroundStopWith( - stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'], - operation: string, - acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void, - /** Untargeted Stop includes work missing from the listed records. */ - untargeted?: { - unlisted: { present: () => boolean; stop: () => Promise } - listed: AgentChildWorkView[] - onJournal?: (journal: AgentSessionJournal) => void - } - ) { - root = await mkdtemp(join(tmpdir(), 'orca-background-task-failed-cancel-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - untargeted?.onJournal?.(journal) - const cancelTurn = vi.fn(async () => ({ cancelled: true })) - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - const result = await runTargetedCancel(acceptedStop(ctx), { - clientOperationId: operation, - turnId: 'background-tasks', - scope: 'background-tasks', - ...(untargeted - ? { unlisted: untargeted.unlisted, childWork: () => untargeted.listed } - : { taskId: 'task-1', childWork: () => [liveTask('task-1')] }), - ...(acknowledgeStops ? { acknowledgeStops } : {}) - }) - expect(cancelTurn).not.toHaveBeenCalled() - return { result, items: journal.snapshot().items } - } - - it('ends the agent only for work unlisted at capture and while no lead turn runs', async () => { - const stop = vi.fn(async () => {}) - const initiallyUnlisted = await backgroundStopWith( - async () => ({ cancelled: false }), - 'cancel-background-initially-unlisted', - undefined, - { unlisted: { present: () => true, stop }, listed: [liveTask('task-1')] } - ) - expect(initiallyUnlisted.result).toEqual({ - ok: true, - value: { turnId: 'background-tasks', cancelled: true } - }) - expect(stop).toHaveBeenCalledOnce() - - // Turns opened during background cancellation must survive that Stop. - let journal: AgentSessionJournal | undefined - const woke = await backgroundStopWith( - async () => { - await journal!.appendItem( - { - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:woke' - }, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'woke', state: 'running' } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return { cancelled: false } - }, - 'cancel-background-unlisted-woke', - undefined, - { - unlisted: { present: () => true, stop }, - listed: [liveTask('task-1')], - onJournal: (opened) => (journal = opened) - } - ) - expect(woke.result).toEqual({ - ok: true, - value: { turnId: 'background-tasks', cancelled: false } - }) - expect(woke.items).toHaveLength(1) - expect(stop).toHaveBeenCalledOnce() - }) - - it('ends the agent for unlisted work with nothing listed, never asking an agent that has no stop', async () => { - const stop = vi.fn(async () => {}) - const stopBackgroundTasks = vi.fn(async () => { - throw new Error('ACP child cancellation is unavailable') - }) - const { result, items } = await backgroundStopWith( - stopBackgroundTasks, - 'cancel-background-unlisted-acp', - undefined, - { - unlisted: { present: () => true, stop }, - listed: [] - } - ) - expect(result).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) - expect(items).toEqual([]) - expect(stopBackgroundTasks).not.toHaveBeenCalled() - expect(stop).toHaveBeenCalledOnce() - }) - - it('ends a run the agent accepted the stop of, even when a later stop in the batch fails', async () => { - const acknowledgeStops = vi.fn() - const { result, items } = await backgroundStopWith( - async ({ acknowledge }) => { - acknowledge?.({ taskId: 'task-1' }) - throw new Error('claude control request timed out') - }, - 'cancel-background-task-partial', - acknowledgeStops - ) - - expect(result).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: false } }) - expect(items).toHaveLength(1) - expect(acknowledgeStops).toHaveBeenCalledWith([ - expect.objectContaining({ type: 'stop-acknowledged', childWorkId: 'child-task-1' }) - ]) - }) -}) - -function acceptedStop(ctx: AgentSessionTurnContext): AgentSessionTurnContext { - return { - ...ctx, - operationReceipt: { - write: () => {}, - committed: () => {}, - isCommitted: () => true, - commitAlone: async () => {} - } - } -} - -function liveTask( - providerId: string, - overrides: Partial = {} -): AgentChildWorkView { - return { - id: `child-${providerId}`, - providerId, - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 1, - observedAt: 1, - stoppable: true, - invocation: { invocationId: `spawn-${providerId}`, generation: 1 }, - ...overrides - } -} - -describe('what a conversation Stop reports when the provider stopped nothing', () => { - async function cancelWith( - outcome: Awaited>, - input: { turnId?: string; withdrewQueued?: boolean }, - turnRow: 'none' | 'running' | 'landing' = 'none' - ) { - root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-report-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const openTurn = () => - journal.appendItem( - { - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' - }, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - if (turnRow === 'running') { - await openTurn() - } - // Issue without awaiting: Stop must queue its read behind this turn write. - const landing = turnRow === 'landing' ? openTurn() : null - const ctx: AgentSessionTurnContext = { - logger: createStructuredAgentSessionLogger(), - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { - acquire: vi.fn(), - open: vi.fn(), - dispatch: vi.fn(), - closeSession: vi.fn(), - cancelTurn: vi.fn(async () => outcome), - answerPrompt: vi.fn(), - setOption: vi.fn() - }, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - now: () => 1 - } - const { withdrewQueued, ...named } = input - const result = await performCancel(ctx, { - clientOperationId: 'cancel-report-1', - ...named, - ...(withdrewQueued === undefined ? {} : { withdrewQueued: Promise.resolve(withdrewQueued) }) - }) - await landing - const rows = journal - .snapshot() - .items.flatMap((item) => - item.body.kind === 'status' && !item.body.turnLifecycle ? [item.body.text] : [] - ) - return { cancelled: result.ok && result.value.cancelled, rows } - } - - it('reports a Stop naming no turn that withdrew what was queued, with nothing left working, as a success', async () => { - expect(await cancelWith({ cancelled: false }, { withdrewQueued: true })).toEqual({ - cancelled: true, - rows: [] - }) - }) - - it('keeps a Stop naming no turn not cancelled while the journal still reads working', async () => { - const reported = await cancelWith({ cancelled: false }, { withdrewQueued: true }, 'running') - expect(reported.cancelled).toBe(false) - expect(reported.rows).toHaveLength(1) - expect(reported.rows).not.toContain('The provider had already finished this turn.') - }) - - it('reads the journal behind its streamed rows: a turn whose send was accepted first is still working', async () => { - const reported = await cancelWith( - { cancelled: false }, - { turnId: 'turn-0', withdrewQueued: true }, - 'landing' - ) - expect(reported).toEqual({ cancelled: false, rows: [] }) - }) -}) - -describe('the note a Stop writes', () => { - it('belongs to the turn whose row was emitted just before the Stop, with no flush', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-note-scope-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind({ journal, fence: 1, publish: () => {} }) - const turnIdentity = { - provider: 'legacy' as const, - agent: 'codex' as const, - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' - } - // Model an earlier stream row still ahead of the named turn. - deferred.sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-0', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Earlier.' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - deferred.sink.appendItem( - turnIdentity, - { - kind: 'status', - text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const ctx: AgentSessionTurnContext = { - sessionId: 'session-1', - journal, - fence: 1, - agents: NO_STRUCTURED_AGENTS, - agent: 'codex', - adapter: { - acquire: vi.fn(), - open: vi.fn(), - dispatch: vi.fn(), - closeSession: vi.fn(), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(), - setOption: vi.fn() - }, - persistOptions: async () => undefined, - resolvedBy: 'client-1', - publish: vi.fn(), - logger: createStructuredAgentSessionLogger(), - now: () => 1 - } - - await performCancel(ctx, { clientOperationId: 'cancel-scope-1', turnId: 'turn-1' }) - await deferred.drained() - - const note = journal - .snapshot() - .items.find( - (item) => item.body.kind === 'status' && item.body.text === 'Cancellation requested.' - ) - expect(note?.turnScope).toEqual({ kind: 'turn', turnItemId: agentJournalItemKey(turnIdentity) }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts index d14b852b57a9..8eb26ecf9ca5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts @@ -105,6 +105,16 @@ async function admittedCodexSend() { const turns = codexTurnLifecycleFake(CODEX_THREAD, () => notify) Object.assign(codex.routes, turns.routes) const provider = adapterFor(codex, {}, [], { + // A Stop ends Codex's app server, so the next send's child resumes the saved thread. + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: restRig?.store.getRecord(SESSION)?.providerHandleChain.length + ? CODEX_THREAD + : null + }), onDispatchSettledLate: (settlement) => void restRig?.host.settleLateDispatch(settlement), onEvent: (event) => { if (event.type === 'ended' && 'cause' in event) { @@ -121,8 +131,10 @@ async function admittedCodexSend() { await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) const id = send.envelope.clientOperationId const interrupts = () => - codex.connections[0]!.calls.filter((call) => call.method === 'turn/interrupt') - return { current, turns, id, interrupts } + codex.connections.flatMap((connection) => + connection.calls.filter((call) => call.method === 'turn/interrupt') + ) + return { current, turns, id, interrupts, codex, notify } } it('Codex: reaches a turn that started before the user echo', async () => { @@ -185,6 +197,71 @@ it('Codex: never answers a quiet success while an admitted turn cannot yet take } }) +it('Codex: distinct Stops during one running turn interrupt once and allow the next reply', async () => { + const { current, turns, id, interrupts, codex, notify } = await admittedCodexSend() + turns.start() + turns.echo(id) + await current.host.flushStreamedEvents(SESSION) + const interruptEntered = Promise.withResolvers() + const releaseInterrupt = Promise.withResolvers() + codex.routes['turn/interrupt'] = async (params) => { + interruptEntered.resolve() + await releaseInterrupt.promise + return turns.routes['turn/interrupt'](params) + } + const first = unnamedStop() + const second = unnamedStop() + expect(second.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + const stopping = current.host.cancel(CALLER, first) + let repeated: ReturnType | undefined + try { + await interruptEntered.promise + expect(turns.turnId).toBe('turn-1') + await vi.waitFor(() => + expect(current.statusEvents.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'working', stopping: true } + }) + ) + // Keep the native request pending so this is another press during Stop, not a late replay. + repeated = current.host.cancel(CALLER, second) + } finally { + releaseInterrupt.resolve() + await stopping + await repeated + } + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await repeated).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(turns.turnId).toBeNull()) + + const next = restTestSend('reply after Stop') + expect(await current.host.send(CALLER, next)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(turns.turnId).toBe('turn-2')) + turns.start() + turns.echo(next.envelope.clientOperationId) + notify('item/completed', { + threadId: CODEX_THREAD, + turnId: 'turn-2', + item: { type: 'agentMessage', id: 'reply-2', text: 'Resumed successfully.' } + }) + turns.end('completed') + await current.host.flushStreamedEvents(SESSION) + const { items } = await current.host.journalSnapshot(SESSION) + expect(interrupts()).toMatchObject([{ params: { threadId: CODEX_THREAD, turnId: 'turn-1' } }]) + expect(items.filter((item) => item.body.kind === 'status').map((item) => item.body)).toEqual([ + { kind: 'status', text: 'Cancellation requested.' } + ]) + expect(items.filter((item) => item.body.kind === 'turn').map((item) => item.body)).toMatchObject([ + { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, + { kind: 'turn', turnId: 'turn-2', state: 'completed' } + ]) + expect(items.map((item) => item.body)).toContainEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Resumed successfully.' }] + }) +}) + it('Codex: a retried Stop replays and never interrupts a later turn', async () => { const { current, turns, interrupts } = await admittedCodexSend() turns.start() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 8fd821f4062f..0490d1efe2e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -253,7 +253,8 @@ it('returns the receipt for a stopped child whose provider reports its own end', cause: 'requested-close', sessionId, fence: child.fence, - acquisitionGeneration: child.generation + acquisitionGeneration: child.generation, + processExit: { root: 'exited', workload: 'unverifiable' } }) } return true diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts deleted file mode 100644 index 6dbb254e4333..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ /dev/null @@ -1,804 +0,0 @@ -import { beforeEach, expect, it, vi, type Mock } from 'vitest' -import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' -import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, - type AgentJournalRenderItem -} from '../../../shared/agent-session-journal-types' -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' -import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { - attach, - CALLER, - envelope, - hostTestState, - replaceHostTestState -} from './structured-agent-session-host-test-harness' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - editPersistedTestAgentSessionStore, - openTestAgentSessionRecordStore -} from '../../runtime/agent-session-record-store-test-harness' -import { StructuredAgentRegistry } from './structured-agent-registry' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestMessage -} from './structured-agent-session-host-test-data' -import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -let state: ReturnType -let compact: Mock> -let closeSession: Mock> - -beforeEach(() => { - state = hostTestState() - compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) - closeSession = vi.fn(async () => true) - Object.assign(state.host.deps.adapter, { compact, closeSession }) -}) - -function finish(result: StructuredConversationCommandOutcome): void { - const { command } = compact.mock.calls.at(-1)![0] - const events = state.acquire.mock.calls.at(-1)![0].events! - const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } - const row: AgentJournalItemBody | null = - result.outcome === 'success' - ? { kind: 'status', text: 'Context compacted', presentation: 'compaction' } - : result.outcome === 'failure' - ? { - kind: 'status', - ...agentSessionFailureWords( - result.failure ?? agentSessionFailureFact('compactionUnconfirmed'), - { surface: 'row' } - ), - tone: 'error' - } - : null - events.appendLifecycleBatch!( - `turn-completed:${command.clientMessageId}`, - [ - ...(row - ? [{ kind: 'item' as const, identity: command.resultIdentity, body: row, turnScope }] - : []), - { - kind: 'item', - identity: command.identity, - body: { - ...command.running, - state: result.outcome === 'cancellation' ? 'interrupted' : 'completed', - outcome: result.outcome, - completedAt: HOST_TEST_NOW - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) -} - -const NOT_ENOUGH = { text: 'Not enough messages to compact.', audience: 'person' as const } - -function compactParams() { - return { - command: 'compact' as const, - envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) - } -} - -function sendParams(text: string) { - const body = hostTestMessage(text) - return { envelope: envelope('agentSession.send', { body }), body } -} - -async function subscribe(): Promise { - const events: AgentSessionSubscribeEvent[] = [] - await state.host.subscribe({ - id: 'pane', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - return events -} - -function frames(events: AgentSessionSubscribeEvent[]): string[] { - return events.flatMap((event) => - event.type === 'batch' - ? [ - ...event.batch.items.map(describeItem), - ...event.batch.submissions.map( - (entry) => `submission:${entry.dispatchState}${entry.handedOverAt ? ':handed' : ''}` - ) - ] - : [] - ) -} - -function describeItem(item: AgentJournalRenderItem): string { - const turn = readAgentJournalTurn(item.body) - if (turn) { - return `turn:${turn.state}${turn.outcome ? `:${turn.outcome}` : ''}` - } - return item.body.kind === 'status' ? `status:${item.body.text}` : item.body.kind -} - -async function journal() { - return state.host.journalSnapshot(SESSION) -} - -async function commandTurn(clientMessageId: string) { - const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) - return (await journal()).items.find((item) => item.itemId === itemId) -} - -it('answers at handover before the provider reports holding the command, then journals its result', async () => { - Object.assign(state.host.deps.adapter, { heldSends: () => new Set() }) - await attach() - const events = await subscribe() - const params = compactParams() - const cmid = params.envelope.clientOperationId - - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ - ok: true, - value: { command: 'compact', state: 'completed' } - }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - finish({ outcome: 'success' }) - - await vi.waitFor(async () => - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('completed') - ) - const facts = frames(events) - const running = facts.indexOf('turn:running') - const ended = facts.indexOf('turn:completed:success') - expect(running, facts.join('\n')).toBeGreaterThanOrEqual(0) - expect(ended).toBeGreaterThan(running) - expect(facts.indexOf('status:Context compacted')).toBeGreaterThan(running) - const turnKey = structuredAgentSessionCommandTurn(cmid).itemId - for (const event of events) { - for (const item of event.type === 'batch' ? event.batch.items : []) { - expect(item.itemId !== turnKey || item.body.kind === 'turn').toBe(true) - } - } - const snapshot = await journal() - const entry = snapshot.items.find((item) => item.body.kind === 'message') - expect(entry?.body).toMatchObject({ command: { name: 'compact' } }) - expect(entry?.turnScope).toEqual(AGENT_JOURNAL_THREAD_SCOPE) - const result = snapshot.items.find( - (item) => item.body.kind === 'status' && item.body.presentation === 'compaction' - ) - expect(result?.turnScope).toEqual({ kind: 'turn', turnItemId: turnKey }) - const submission = snapshot.submissions.find((item) => item.clientMessageId === cmid) - expect(submission).toMatchObject({ dispatchState: 'accepted', providerItemId: null }) - expect(AgentJournalSubmissionSchema.safeParse(submission).success).toBe(true) - expect(isAdmissibleAgentJournalItemBody(entry!.body)).toBe(true) - expect(state.dispatch).not.toHaveBeenCalled() -}) - -it('replays the reply for the same operation without running it again (B16)', async () => { - await attach() - const params = compactParams() - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - finish({ outcome: 'success' }) - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true - }) - expect(compact).toHaveBeenCalledOnce() -}) - -it('holds messages sent during the command and delivers them after it, in order (B2)', async () => { - await attach() - const params = compactParams() - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - await expect(state.host.send(CALLER, sendParams('first'))).resolves.toMatchObject({ ok: true }) - await expect(state.host.send(CALLER, sendParams('second'))).resolves.toMatchObject({ ok: true }) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(state.dispatch).not.toHaveBeenCalled() - - finish({ - outcome: 'failure', - failure: agentSessionFailureFact('compactionFailed', { detail: NOT_ENOUGH }) - }) - - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2), { interval: 1 }) - expect(state.dispatch.mock.calls.map(([input]) => input.body.blocks)).toEqual([ - [{ type: 'text', text: 'first' }], - [{ type: 'text', text: 'second' }] - ]) - const turn = await commandTurn(params.envelope.clientOperationId) - expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'completed', outcome: 'failure' }) - const error = (await journal()).items.find( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - ) - expect(error?.body).toMatchObject({ - text: 'Compaction failed: Not enough messages to compact.', - failure: { kind: 'compactionFailed', detail: NOT_ENOUGH } - }) -}) - -it('hands over a message held behind the command when the command ends just as the loop stops for it', async () => { - await attach() - await state.host.conversationCommand(CALLER, compactParams()) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - const { journal: live } = state.host['sessions'].get(SESSION)! - const runningTurn = live.runningTurn - let ended = false - vi.spyOn(live, 'runningTurn').mockImplementation(() => { - const read = runningTurn() - // End the command during the delivery loop’s own running-state read. - if ( - !ended && - read?.turnId.startsWith('compact:') && - /at (?:StructuredAgentSessionDeliveryLoop\.)?prepare \(.*structured-agent-session-delivery-loop/.test( - new Error('who reads').stack ?? '' - ) - ) { - ended = true - finish({ outcome: 'success' }) - } - return read - }) - - await expect(state.host.send(CALLER, sendParams('held'))).resolves.toMatchObject({ ok: true }) - - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(ended).toBe(true) -}) - -it('settles a command the provider refused as a failure with its reason, and moves on (B3)', async () => { - await attach() - compact.mockResolvedValue({ - state: 'rejected', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), - { - surface: 'rejection' - } - ) - }) - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await state.host.send(CALLER, sendParams('after the refusal')) - - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ - state: 'completed', - outcome: 'failure' - }) - const snapshot = await journal() - expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)).toMatchObject({ - dispatchState: 'rejected', - reason: "The agent didn't accept this message: Not enough messages to compact.", - rejection: { kind: 'providerRejected', detail: NOT_ENOUGH } - }) - expect( - snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') - ).toEqual([ - expect.objectContaining({ - body: { - kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', - failure: { kind: 'compactionFailed', detail: NOT_ENOUGH }, - tone: 'error' - }, - turnScope: { kind: 'turn', turnItemId: structuredAgentSessionCommandTurn(cmid).itemId } - }) - ]) -}) - -it('says only that the compaction failed when the provider refused it without words', async () => { - await attach() - compact.mockResolvedValue({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }) - }) - const params = compactParams() - await state.host.conversationCommand(CALLER, params) - - await vi.waitFor(async () => - expect( - (await journal()).items - .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') - .map((item) => item.body) - ).toEqual([ - { - kind: 'status', - text: 'Compaction failed.', - failure: { kind: 'compactionFailed' }, - tone: 'error' - } - ]) - ) -}) - -it('refuses the command for an agent that does not declare compaction, whatever its adapter has', async () => { - const declared = CODEX_STRUCTURED_AGENT.capabilities - const agents = new StructuredAgentRegistry([ - { - definition: { - ...CODEX_STRUCTURED_AGENT, - capabilities: { ...declared, compact: false, threadGoal: false, rewind: false } - }, - adapter: state.host.deps.adapter - } - ]) - replaceHostTestState({ - store: state.store, - host: new StructuredAgentSessionHost({ ...state.host.deps, agents }) - }) - state = hostTestState() - await attach() - const params = compactParams() - - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ - ok: true, - value: { state: 'completed', failure: { kind: 'commandRefused' } } - }) - expect(compact).not.toHaveBeenCalled() - expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() -}) - -it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { - await attach() - const events = state.acquire.mock.calls.at(-1)?.[0].events - const params = compactParams() - // Hold handover so a provider turn can open after command acceptance but before delivery. - const held = Promise.withResolvers() - void state.host['tasks'].serialize(SESSION, () => held.promise) - const commanded = state.host.conversationCommand(CALLER, params) - void state.host['tasks'].serialize(SESSION, async () => { - await events?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } - ) - }) - held.resolve() - - const refused = { - kind: 'commandRefused', - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } - } - await expect(commanded).resolves.toMatchObject({ - ok: true, - value: { - state: 'completed', - error: "The agent is still working. Run /compact when it's done.", - failure: refused - } - }) - expect(compact).not.toHaveBeenCalled() - expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() - expect( - (await journal()).submissions.find( - (entry) => entry.clientMessageId === params.envelope.clientOperationId - ) - ).toMatchObject({ - dispatchState: 'rejected', - reason: "The agent is still working. Run /compact when it's done.", - rejection: refused - }) -}) - -it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { - await attach() - await state.host.close(SESSION, 'evict') - state.acquire.mockRejectedValue(new Error('not signed in')) - const params = compactParams() - - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ - ok: true, - value: { - state: 'completed', - error: "Codex couldn't restart. Run /compact again.", - failure: { kind: 'restartFailed' } - } - }) - const snapshot = await journal() - expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) - expect( - snapshot.items - .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') - .map((item) => item.body) - ).toEqual([ - { - kind: 'status', - text: "Codex couldn't restart. Run /compact again.", - failure: expect.objectContaining({ kind: 'restartFailed' }), - tone: 'error' - } - ]) - expect( - snapshot.submissions.find( - (entry) => entry.clientMessageId === params.envelope.clientOperationId - ) - ).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Run /compact again." - }) - expect(compact).not.toHaveBeenCalled() -}) - -function stop(turnId: string) { - return state.host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId }), - turnId - }) -} - -it('leaves the command to the provider when it takes the Stop, and ends it as cancelled (B4)', async () => { - await attach() - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - const { turnId } = structuredAgentSessionCommandTurn(cmid) - - await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) - expect(closeSession).not.toHaveBeenCalled() - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') - finish({ outcome: 'cancellation' }) - await vi.waitFor(async () => - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) - ) - expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) -}) - -it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { - await attach() - const statuses: AgentSessionStatusEvent[] = [] - state.host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - const { turnId } = structuredAgentSessionCommandTurn(cmid) - - await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - expect(closeSession).not.toHaveBeenCalled() - expect(latestSummary(statuses)).toMatchObject({ status: 'working', stopping: true }) - await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - - expect(state.cancelTurn).toHaveBeenCalledOnce() - expect(closeSession).toHaveBeenCalledOnce() - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') - expect(latestSummary(statuses)).not.toHaveProperty('stopping') -}) - -function latestSummary(events: AgentSessionStatusEvent[]): AgentSessionStatusSummary | undefined { - return events - .flatMap((event) => - event.type === 'status' ? [event.session] : event.type === 'snapshot' ? event.sessions : [] - ) - .findLast((summary) => summary.sessionId === SESSION) -} - -it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { - await attach() - state.cancelTurn.mockResolvedValue({ cancelled: false }) - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - - await expect(stop(structuredAgentSessionCommandTurn(cmid).turnId)).resolves.toMatchObject({ - ok: true, - value: { cancelled: true } - }) - - expect(closeSession).toHaveBeenCalledOnce() - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') - const notes = (await journal()).items.filter((item) => item.body.kind === 'status') - expect(notes.map((item) => item.body.kind === 'status' && item.body.text)).toEqual([ - 'Cancellation requested.' - ]) - await state.host.send(CALLER, sendParams('after the stop')) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(state.acquire).toHaveBeenCalledTimes(2) -}) - -it('does not stop the child for a Stop naming a command that already ended (B4)', async () => { - await attach() - state.cancelTurn.mockResolvedValue({ cancelled: false }) - const params = compactParams() - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - finish({ outcome: 'success' }) - const { itemId, turnId } = structuredAgentSessionCommandTurn(params.envelope.clientOperationId) - await vi.waitFor(async () => - expect( - readAgentJournalTurn((await journal()).items.find((item) => item.itemId === itemId)?.body) - ?.state - ).toBe('completed') - ) - - await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) - expect(closeSession).not.toHaveBeenCalled() -}) - -it("answers a refused command's message before ending its turn, so a crash between them leaves a turn the sweep settles (B4)", async () => { - await attach() - // Only provider refusal uses host settlement; provider-authored ends already land atomically. - compact.mockResolvedValue({ - state: 'rejected', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), - { - surface: 'rejection' - } - ) - }) - const { journal: live } = state.host['sessions'].get(SESSION)! - const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) - const crash = vi - .spyOn(live, 'appendLifecycleBatch') - .mockImplementation((input) => - input.settlementId.startsWith('command-settled:') - ? Promise.reject(new Error('host crashed')) - : appendLifecycleBatch(input) - ) - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(crash).toHaveBeenCalled(), { interval: 1 }) - crash.mockRestore() - - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') - expect( - (await journal()).submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState - ).toBe('rejected') - await settleStaleStructuredAgentSessionState({ - journal: live, - sessionId: SESSION, - fence: state.store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: null, - deathEvidence: null - }) - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') -}) - -it('counts a message held behind the command from its handover, not its send', async () => { - await attach() - await state.host.conversationCommand(CALLER, compactParams()) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - const sent = await state.host.send(CALLER, sendParams('held')) - expect(sent.ok).toBe(true) - await new Promise((resolve) => setTimeout(resolve, 20)) - finish({ outcome: 'success' }) - - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - const [dispatched] = state.dispatch.mock.calls[0]! - const submission = (await journal()).submissions.find( - (entry) => entry.clientMessageId === dispatched.clientMessageId - ) - expect(submission?.handedOverAt).toBeGreaterThan(submission!.submittedAt) - expect(dispatched.requestedAt).toBe(submission?.handedOverAt) -}) - -it('settles a command whose adapter call threw after the start as unknown (B4)', async () => { - await attach() - compact.mockImplementation(() => { - throw new Error('codex app-server session is not live') - }) - const params = compactParams() - const cmid = params.envelope.clientOperationId - - await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) - - await vi.waitFor(async () => - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') - ) - expect( - (await journal()).submissions.find((entry) => entry.clientMessageId === cmid) - ).toMatchObject({ dispatchState: 'unknown', reason: 'codex app-server session is not live' }) -}) - -it('writes one exit row when the child dies mid-command, and the loop writes nothing but moves on (B4)', async () => { - state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${fence}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: 1 - } - })) - await attach() - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - await expect(state.host.send(CALLER, sendParams('queued behind it'))).resolves.toMatchObject({ - ok: true - }) - - const fence = state.store.getRecord(SESSION)!.lease.runtimeFence - await state.host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence, - acquisitionGeneration: `generation-${fence}`, - reason: 'provider exited', - cause: 'unexpected-exit' - }) - - await vi.waitFor(async () => - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') - ) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(state.acquire).toHaveBeenCalledTimes(2) - const snapshot = await journal() - expect( - snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') - ).toHaveLength(1) - expect(snapshot.items.some((item) => item.itemId.includes('command-result'))).toBe(false) - // Acknowledged command messages remain delivered even when their turn is interrupted. - expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState).toBe( - 'accepted' - ) -}) - -it('delivers the next message after a command whose child died and whose settlement could not be written', async () => { - state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${fence}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: 1 - } - })) - await attach() - const params = compactParams() - const cmid = params.envelope.clientOperationId - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - const { journal: live } = state.host['sessions'].get(SESSION)! - const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) - vi.spyOn(live, 'appendLifecycleBatch').mockImplementation((input) => - input.settlementId.includes('provider-exit:') - ? Promise.reject(new Error('disk full')) - : appendLifecycleBatch(input) - ) - - const fence = state.store.getRecord(SESSION)!.lease.runtimeFence - await state.host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence, - acquisitionGeneration: `generation-${fence}`, - reason: 'provider exited', - cause: 'unexpected-exit' - }) - await vi.waitFor(() => expect(state.host['sessions'].get(SESSION)?.child).toBeNull()) - await vi.waitFor(async () => - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') - ) - - await expect(state.host.send(CALLER, sendParams('after it'))).resolves.toMatchObject({ - ok: true - }) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(state.acquire).toHaveBeenCalledTimes(2) - expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') -}) - -it('ignores a ledger-only compaction record with no command receipt (B15)', async () => { - await attach() - const older = compactParams() - await state.store.admitMutationOperation({ - callerKey: CALLER.callerKey, - envelope: older.envelope, - hostFingerprint: older.envelope.payloadFingerprint, - now: HOST_TEST_NOW - }) - const fence = state.store.getRecord(SESSION)!.lease.runtimeFence - await state.host.close(SESSION, 'evict') - await state.host.flushAllStreamedEvents() - await editPersistedTestAgentSessionStore(state.root, (persisted) => { - persisted.records[SESSION].conversationCommand = { - command: 'compact', - runtimeFence: fence, - operationId: older.envelope.clientOperationId, - callerKey: CALLER.callerKey, - phase: 'prepared', - state: 'unknown' - } - }) - const store = await openTestAgentSessionRecordStore(state.root) - replaceHostTestState({ - store, - host: new StructuredAgentSessionHost({ ...state.host.deps, store }) - }) - state = hostTestState() - await state.host.restoreReadableSessions(store.listVisibleSessionIds()) - - await expect(state.host.send(CALLER, sendParams('still works'))).resolves.toMatchObject({ - ok: true - }) - await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - await expect(state.host.conversationCommand(CALLER, compactParams())).resolves.toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - finish({ outcome: 'success' }) - await vi.waitFor(async () => - expect((await journal()).submissions.every((entry) => entry.dispatchState === 'accepted')).toBe( - true - ) - ) - - await expect(state.host.conversationCommand(CALLER, older)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { state: 'completed' } - }) - expect(compact).toHaveBeenCalledTimes(2) -}) - -it('never lets a provider echo alias the command entry', async () => { - await attach() - const params = compactParams() - await state.host.conversationCommand(CALLER, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - finish({ outcome: 'success' }) - const events = state.acquire.mock.calls.at(-1)?.[0].events - const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } - events?.appendItem( - echo, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await state.host.flushStreamedEvents(SESSION) - expect((await journal()).items.some((item) => item.itemId === agentJournalItemKey(echo))).toBe( - true - ) -}) - -it('refuses a /compact pressed again under a new id while one runs, and runs one pressed after it ended', async () => { - await attach() - await state.host.conversationCommand(CALLER, compactParams()) - await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) - - expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ - ok: false, - refusal: { details: { reason: 'turnActive' } } - }) - expect(compact).toHaveBeenCalledOnce() - - finish({ outcome: 'success' }) - await vi.waitFor(async () => - expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ - ok: true - }) - ) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(2), { interval: 1 }) -}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 324422f384f5..7ed6bf4a4b63 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -158,9 +158,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { await session.connection.request('abort', {}, { timeoutMs: 2_000 }) return { cancelled: true, ...(turnId ? { turnId } : {}) } } - stopEndsSession(): boolean { - return true - } awaitStoppedRequestEnd: NonNullable = async (id, at) => { const session = this.sessions.get(id) diff --git a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts index ee3876301df7..4e9b63c70669 100644 --- a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts +++ b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts @@ -146,8 +146,11 @@ it('ends a command Codex has not opened a turn for by stopping its child, and th expect(first.closed).toBe(true) expect(first.calls.some((entry) => entry.method === 'turn/interrupt')).toBe(false) - const turn = (await snapshot()).items.find((item) => item.itemId === itemId) - expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + // The child's end, the Stop's second step, settles the command's turn. + await vi.waitFor(async () => { + const turn = (await snapshot()).items.find((item) => item.itemId === itemId) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + }) await send('after the stop') await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) expect(calls('turn/start')[0]).not.toBe(first) diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index 617ff88b0db3..9d562898edcf 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -97,6 +97,7 @@ async function stop(turnId?: string): Promise { if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) } + await host.collaboratorsForTests().serialize(SESSION, async () => {}) } /** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */ diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts index f31fdad35f4c..7c42f7208961 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -79,6 +79,35 @@ it('attaches at spawn and holds sends until the scripted Codex handshake answers await vi.waitFor(() => expect(child.prompts()).toEqual(['held until Codex is ready'])) }) +it('ends a running Codex child on Stop and resumes its thread for the next send', async () => { + const child = codexScriptedChild() + child.holdHandshakes = false + child.completeTurns = false + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('first turn') + expect( + (await host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body })).ok + ).toBe(true) + await vi.waitFor(() => expect(child.prompts()).toEqual(['first turn'])) + expect( + (await host.cancel(caller, { envelope: envelope('agentSession.cancel', fence, {}) })).ok + ).toBe(true) + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) + expect(child.closes()).toBe(1) + const next = hostTestMessage('next turn') + expect( + ( + await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body: next }), + body: next + }) + ).ok + ).toBe(true) + await vi.waitFor(() => expect(child.prompts()).toEqual(['first turn', 'next turn'])) + expect(child.spawns()).toBe(2) + expect(child.resumes()).toBe(1) +}) + it('can Stop the scripted child during initialization and settle a held send as withdrawn', async () => { const child = codexScriptedChild() const { host, fence } = await attachedHost(child) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 21f03deea848..f5513cf98dbb 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -123,9 +123,14 @@ async function send(text: string): Promise { } /** The chat's Stop; `turnId` names the turn it stops, as the phone does. */ -function stop(turnId?: string): Promise { +async function stop(turnId?: string): Promise { const fields = turnId === undefined ? {} : { turnId } - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + return result } function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { @@ -314,7 +319,7 @@ describe('a send a Stop took back, then a send made while it read Stopping', () turns.end('completed') await vi.waitFor(async () => expect(verdictOf(await submissions(), second)).toBe('accepted')) const live = await drawn() - expect(live).toEqual([ + expect(live.filter((text) => text !== 'Cancellation requested.')).toEqual([ 'look around', NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, 'sent while stopping' @@ -418,6 +423,12 @@ describe('a send a Stop took back before Codex echoed it', () => { ['user', [{ type: 'text', text: 'look around' }]], ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] ]) - expect(await drawn()).toEqual(['warm up', 'look around', NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) + const rows = await drawn() + expect(rows.filter((text) => text === 'Cancellation requested.')).toHaveLength(1) + expect(rows.filter((text) => text !== 'Cancellation requested.')).toEqual([ + 'warm up', + 'look around', + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT + ]) }) }) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 8954c3e38fde..abe04509b0c1 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -25,8 +25,6 @@ import type { AgentJournalSubmission } from '../../shared/agent-session-journal- import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { structuredAgentSessionStopNoteIdentity } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -159,6 +157,7 @@ async function stop(turnId?: string): Promise { if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) } + await host.collaboratorsForTests().serialize(SESSION, async () => {}) } /** `/compact` as the chat surface runs it; refused while the chat still owes work. */ @@ -593,6 +592,7 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur expect(await held).toBe('held') expect(interrupts).toBe(1) turns.start() + await vi.advanceTimersByTimeAsync(1) await stopping expect(interrupts).toBe(2) @@ -620,15 +620,19 @@ describe('a Stop in that window that the turn never opens for', () => { return { stopping, sent } } - it('says Codex had no turn running when that turn ends first', async () => { - const { stopping } = await waitingStop() + it('ends the session even when that turn ends before the interrupt can take it', async () => { + const { stopping, sent } = await waitingStop() turns.end('interrupted') await stopping expect(interrupts).toBe(1) - expect(childCloses).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + expect(childCloses).toBe(1) + // The request stays visible; the withdrawn send has its own stopped result. + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + expect(await statusRows()).toEqual(['Cancellation requested.']) }) it('lets a chat closed behind it close within its bound and one eviction', async () => { @@ -639,10 +643,7 @@ describe('a Stop in that window that the turn never opens for', () => { closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS ) - await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) - expect(openWaits.ended).toEqual([]) - expect(childCloses).toBe(0) - await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) expect(openWaits.ended).toEqual(['turn-1']) expect(await closedWithinBound).not.toBe('held') @@ -661,10 +662,7 @@ describe('a Stop in that window that the turn never opens for', () => { stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS ) - await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) - expect(openWaits.ended).toEqual([]) - expect(childCloses).toBe(0) - await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) expect(openWaits.ended).toEqual(['turn-1']) expect(await quitWithinBound).not.toBe('held') expect(childCloses).toBe(1) @@ -715,9 +713,8 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) }) - // The Stop was read with no turn running; its note is the turn the interrupt took, as a normal - // Stop's is, never a conversation row below whatever came next. - it('keeps its note with the turn the interrupt took', async () => { + // The request was recorded before a turn was known, so a later turn cannot take its identity. + it('keeps its request separate from a turn that opens after acceptance', async () => { const release = turns.holdNextAnswer() await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) @@ -733,12 +730,8 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { const notes = items.filter( (item) => item.body.kind === 'status' && item.body.text === 'Cancellation requested.' ) - expect(notes.map((note) => [note.itemId, note.turnScope])).toEqual([ - [ - agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')), - { kind: 'turn', turnItemId: turn?.itemId } - ] - ]) + expect(notes.map((note) => note.turnScope)).toEqual([{ kind: 'thread' }]) + expect(notes[0]?.itemId).toMatch(/^orca:stop%3A\d{13}-/) }) it('ends the child when its interrupt was refused and the turn has not opened by the end of its wait', async () => { @@ -779,8 +772,8 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await nextSendStartsANewChild() }) - // Nothing it stopped stays in the transcript, so a Stop row would read as stopping the turn before. - it('leaves no Stop row under the turn before once it takes the send back', async () => { + // The accepted request remains separate from the previous completed turn. + it('keeps the Stop request after taking back a send, without changing the previous turn', async () => { const warmUp = await send('warm up') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() @@ -792,7 +785,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await stopWhenItsTurnNeverOpens(release) expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') - expect(await statusRows()).toEqual([]) + expect(await statusRows()).toEqual(['Cancellation requested.']) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ state: 'completed' }) @@ -861,7 +854,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await stopWhenItsTurnNeverOpens() - expect(childCloses).toBe(1) + expect(childCloses).toBe(2) expect(verdictOf((await settled()).submissions, next)).toBe('withdrawn') }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 19d7ac02e161..fe001556e51b 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -286,9 +286,8 @@ async function subscribe( /** Settles everything the provider streamed into the journal. Real clients see * these rows arrive on the subscription; a test has to wait for them. */ -function drainStreamedEvents(): Promise { - return getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() -} +const drainStreamedEvents = (): Promise => + getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() function textOf(item: AgentJournalRenderItem): string { const body = item.body @@ -691,16 +690,18 @@ describe('a structured codex session over agentSession.*', () => { ).toBe(true) expect(itemsOf(missed).map(textOf).filter(Boolean)).toEqual(['Stopped.']) - // A runtime taking the session over is the other half of reconnect: the - // fence advances, the old child is reaped, and its replacement resumes the - // thread this session proved rather than forking a new one. + // Stop ended the session once its turn did. A runtime taking it over is the other half of + // reconnect: the fence advances, and the new child resumes the thread this session proved + // rather than forking a new one. const reaped = codex.live() + await vi.waitFor(() => expect(reaped.closed).toBe(true)) + const stoppedFence = + getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? fence const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( 'agentSession.ensure', - attachParams(fence) + attachParams(stoppedFence) ) - expect(resumed.fence).toBe(fence + 1) - expect(reaped.closed).toBe(true) + expect(resumed.fence).toBe(stoppedFence + 1) expect(codex.live().resumedThreadId).toBe(THREAD) expect(await readKnownOptions()).toMatchObject({ ok: true, diff --git a/src/main/runtime/structured-foreground-command-strip.test.ts b/src/main/runtime/structured-foreground-command-strip.test.ts deleted file mode 100644 index c2a75f92b7d8..000000000000 --- a/src/main/runtime/structured-foreground-command-strip.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { AgentHookServer, _internals } from '../agent-hooks/server' -import { fakeCodex, THREAD_ID } from '../codex/codex-structured-session-adapter-fixture' -import { - HOST_TEST_SESSION as SESSION, - hostTestAttachParams -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' -import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' - -vi.mock('../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) - -describe('foreground commands in the background-task channel', () => { - let root: string - let server: AgentHookServer - - beforeEach(async () => { - _internals.resetCachesForTests() - root = await mkdtemp(join(tmpdir(), 'orca-foreground-command-strip-')) - server = new AgentHookServer() - }) - - afterEach(async () => { - await stopStructuredAgentSessionRuntime() - await rm(root, { recursive: true, force: true }) - }) - - async function rig(routes: Parameters[0] = {}) { - const codex = fakeCodex({ - ...routes, - 'model/list': () => ({ - data: [ - { - model: 'gpt-test', - displayName: 'GPT Test', - hidden: false, - supportedReasoningEfforts: [], - defaultReasoningEffort: null, - isDefault: true - } - ], - nextCursor: null - }) - }) - const host = await ensureStructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }), - resolveCodexCommand: () => 'codex', - resolveEnvironment: async () => ({ PATH: process.env.PATH }), - ...scriptedCodexTransport(codex.openConnection), - statusSink: { - publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), - forget: (subject) => server.dropStructuredStatus(subject), - publishChildWork: (subject, evidence, provider) => - server.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => server.getStructuredChildWorkViews(subject), - readChildWorkHandles: (subject) => server.getStructuredChildWorkHandles(subject) - } - }) - const params = hostTestAttachParams(null, { providerHandle: undefined }) - params.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` - const attached = await host.attach({ callerKey: 'strip-test' }, params) - expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) - const rosters: (AgentSessionBackgroundTaskState | null)[] = [] - await host.subscribe({ - id: 'strip', - sessionId: SESSION, - emit: (event) => { - if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { - rosters.push(event.backgroundTasks) - } - } - }) - const settle = async () => { - for (let tick = 0; tick < 5; tick += 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - } - const notify = async (method: string, params: Record) => { - codex.connections[0]?.handlers.onNotification?.(method, params) - await settle() - } - const turn = (method: string, id: string) => - notify(method, { threadId: THREAD_ID, turn: { id, status: 'completed' } }) - const command = (method: string, id: string, turnId = 'turn-1', processId?: string) => - notify(method, { - threadId: THREAD_ID, - turnId, - item: { - type: 'commandExecution', - id, - ...(processId ? { processId } : {}), - command: id === 'pwd' ? 'pwd' : 'git status --short --branch', - source: 'unifiedExecStartup', - status: method === 'item/completed' ? 'completed' : 'inProgress' - } - }) - return { - host, - rosters, - notify, - turn, - command, - fence: attached.ok ? attached.value.fence : null - } - } - - type StripHost = Awaited>['host'] - - /** The strip's Stop on a row (the `server` command by default), as a client sends it. */ - function stopFromStrip( - host: StripHost, - fence: number | null, - operation: string, - taskId = 'codex-command:primary:server' - ) { - const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId } - return host.cancel( - { callerKey: 'strip-test' }, - { - envelope: { - sessionId: SESSION, - clientOperationId: `${Date.now()}-${operation.padStart(32, '0')}`, - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields - }) - }, - ...fields - } - ) - } - - it('never publishes a strip between a quick command starting and completing', async () => { - const { host, rosters, turn, command } = await rig() - await turn('turn/started', 'turn-1') - for (const id of ['pwd', 'status']) { - await command('item/started', id) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.page.items).toContainEqual( - expect.objectContaining({ - body: expect.objectContaining({ kind: 'tool-call', callId: id }) - }) - ) - // Observe every publication, including the start frame before the completion arrives. - expect(rosters.every((roster) => roster === null)).toBe(true) - expect(page.page.backgroundTasks).toBeNull() - await command('item/completed', id) - } - await turn('turn/completed', 'turn-1') - expect(rosters.every((roster) => roster === null)).toBe(true) - }) - - it('shows a surviving command after its own turn ends and throughout the next turn', async () => { - const { rosters, turn, command } = await rig() - await turn('turn/started', 'turn-1') - await command('item/started', 'server') - await turn('turn/completed', 'turn-1') - expect(rosters.at(-1)?.tasks).toContainEqual( - expect.objectContaining({ id: 'codex-command:primary:server', kind: 'command' }) - ) - await turn('turn/started', 'turn-2') - await command('item/started', 'pwd', 'turn-2') - expect(rosters.at(-1)?.tasks?.map((task) => task.id)).toEqual(['codex-command:primary:server']) - await command('item/completed', 'pwd', 'turn-2') - await command('item/completed', 'server') - expect(rosters.at(-1)).toBeNull() - }) - - it('stops a surviving command from the strip through Codex background terminals', async () => { - let running = ['4242'] - const { host, rosters, turn, command, fence } = await rig({ - 'thread/backgroundTerminals/list': () => ({ - data: running.map((processId) => ({ processId })), - nextCursor: null - }), - 'thread/backgroundTerminals/terminate': (params) => { - running = running.filter((processId) => processId !== params?.processId) - return { terminated: true } - } - }) - await turn('turn/started', 'turn-1') - await command('item/started', 'server', 'turn-1', '4242') - await turn('turn/completed', 'turn-1') - expect(rosters.at(-1)).toMatchObject({ - supportsTaskStop: true, - children: [{ providerId: 'codex-command:primary:server', stoppable: true }] - }) - - const stopped = await stopFromStrip(host, fence, '2') - expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(running).toEqual([]) - // The killed process's own end is what takes its row off the strip. - await command('item/completed', 'server', 'turn-1', '4242') - expect(rosters.at(-1)).toBeNull() - }) - - /** The words of every note a Stop wrote, oldest first. */ - async function stopNotes(host: StripHost): Promise { - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - return page.page.items.flatMap((item) => - item.body.kind === 'status' && item.body.failure ? [item.body.text] : [] - ) - } - - it('notes a Stop whose command still runs, and keeps the row stoppable', async () => { - const { host, rosters, turn, command, fence } = await rig({ - 'thread/backgroundTerminals/list': () => ({ - data: [{ processId: '4242' }], - nextCursor: null - }), - 'thread/backgroundTerminals/terminate': () => ({ terminated: false }) - }) - await turn('turn/started', 'turn-1') - await command('item/started', 'server', 'turn-1', '4242') - await turn('turn/completed', 'turn-1') - - await expect(stopFromStrip(host, fence, '3')).resolves.toMatchObject({ - ok: true, - value: { cancelled: false } - }) - expect(await stopNotes(host)).toEqual(["The background task wasn't stopped."]) - expect(rosters.at(-1)).toMatchObject({ - children: [{ providerId: 'codex-command:primary:server', stoppable: true }] - }) - }) - - it('notes a Stop that lost contact with Codex as unconfirmed, and keeps the row stoppable', async () => { - const { host, rosters, turn, command, fence } = await rig({ - 'thread/backgroundTerminals/list': () => ({ - data: [{ processId: '4242' }], - nextCursor: null - }), - 'thread/backgroundTerminals/terminate': () => { - throw new Error('codex app-server connection closed') - } - }) - await turn('turn/started', 'turn-1') - await command('item/started', 'server', 'turn-1', '4242') - await turn('turn/completed', 'turn-1') - - await expect(stopFromStrip(host, fence, '4')).resolves.toMatchObject({ - ok: true, - value: { cancelled: false } - }) - expect(await stopNotes(host)).toEqual(['Cancellation was not confirmed.']) - expect(rosters.at(-1)).toMatchObject({ - children: [{ providerId: 'codex-command:primary:server', stoppable: true }] - }) - }) - - it('stops a sub-agent from the strip by interrupting its own turn', async () => { - const helper = 'thread-helper' - const interrupts: unknown[] = [] - const { host, rosters, notify, turn, fence } = await rig({ - 'turn/interrupt': (params) => { - interrupts.push(params) - return {} - } - }) - await turn('turn/started', 'turn-1') - await notify('item/started', { - threadId: THREAD_ID, - turnId: 'turn-1', - item: { - type: 'subAgentActivity', - id: 'spawn-helper', - kind: 'started', - agentThreadId: helper, - agentPath: '/root/helper' - } - }) - await notify('turn/started', { - threadId: helper, - turn: { id: 'helper-turn', status: 'inProgress' } - }) - await turn('turn/completed', 'turn-1') - expect(rosters.at(-1)).toMatchObject({ - supportsTaskStop: true, - children: [{ providerId: helper, kind: 'agent', stoppable: true }], - // An older client's row names `codex-agent:`, which no record carries. - tasks: [{ id: `codex-agent:${helper}`, stoppable: false }] - }) - - await expect(stopFromStrip(host, fence, '5', helper)).resolves.toMatchObject({ - ok: true, - value: { cancelled: true } - }) - expect(interrupts).toEqual([{ threadId: helper, turnId: 'helper-turn' }]) - // The child's own interrupted turn is what takes its row off the strip. - await notify('turn/completed', { - threadId: helper, - turn: { id: 'helper-turn', status: 'interrupted' } - }) - expect(rosters.at(-1)).toBeNull() - }) - - it("stops a sub-agent's running command with it, and the row leaves", async () => { - const helper = 'thread-helper' - let running = ['5151'] - const { host, rosters, notify, turn, fence } = await rig({ - 'turn/interrupt': () => ({}), - 'thread/backgroundTerminals/list': () => ({ - data: running.map((processId) => ({ processId })), - nextCursor: null - }), - 'thread/backgroundTerminals/terminate': (params) => { - running = running.filter((processId) => processId !== params?.processId) - return { terminated: true } - } - }) - const helperCommand = (status: 'inProgress' | 'completed') => - notify(status === 'completed' ? 'item/completed' : 'item/started', { - threadId: helper, - turnId: 'helper-turn', - item: { - type: 'commandExecution', - id: 'helper-exec', - processId: '5151', - command: 'sleep 90', - source: 'unifiedExecStartup', - status - } - }) - await turn('turn/started', 'turn-1') - await notify('item/started', { - threadId: THREAD_ID, - turnId: 'turn-1', - item: { - type: 'subAgentActivity', - id: 'spawn-helper', - kind: 'started', - agentThreadId: helper, - agentPath: '/root/helper' - } - }) - await notify('turn/started', { - threadId: helper, - turn: { id: 'helper-turn', status: 'inProgress' } - }) - await helperCommand('inProgress') - await turn('turn/completed', 'turn-1') - - await expect(stopFromStrip(host, fence, '6', helper)).resolves.toMatchObject({ - ok: true, - value: { cancelled: true } - }) - expect(running).toEqual([]) - await notify('turn/completed', { - threadId: helper, - turn: { id: 'helper-turn', status: 'interrupted' } - }) - await helperCommand('completed') - expect(rosters.at(-1)).toBeNull() - }) -}) diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts index 6ac40703d6ab..e7c790a4adfb 100644 --- a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -52,15 +52,23 @@ export type ScenarioPort = { ) => Promise runtimeStub: () => unknown } -export const turnIdentity = { provider: 'orca', clientMessageId: 'turn-stop-test' } as const +export const turnIdentity = { + provider: 'orca', + clientMessageId: 'turn-stop-test' +} as const export const turnItemId = agentJournalItemKey(turnIdentity) -export const noteIdentity = { provider: 'orca', clientMessageId: 'stop:turn-stop-test' } as const +export const noteIdentity = { + provider: 'orca', + clientMessageId: 'stop:turn-stop-test' +} as const export const noteId = agentJournalItemKey(noteIdentity) export const scope = { kind: 'turn', turnItemId } as const export const ordinary = { kind: 'status', text: 'Cancellation requested.' } export const unconfirmed: AgentJournalItemBody = { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) } export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { @@ -190,13 +198,24 @@ export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { settlementId: `end-${state}`, fence: 1, mutations: [ - { kind: 'item', identity: turnIdentity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { + kind: 'item', + identity: turnIdentity, + body, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] }) - : journal.appendItem(turnIdentity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + : journal.appendItem(turnIdentity, body, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) } async function note(body: AgentJournalItemBody = unconfirmed) { - await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) + await journal.appendItem(noteIdentity, body, { + fence: 1, + turnScope: scope + }) } async function seed() { await turn('running')