diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 160002722a5e..1818c7f16555 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-09-11", + "updatedAt": "2026-10-13", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1478,14 +1478,14 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.", "oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2" ], "testFiles": [ "src/shared/agent-session-operation-ledger.test.ts", "src/shared/structured-agent-session-send-evidence.test.ts", "src/main/runtime/agent-session-operation-admission.test.ts", - "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts", "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts", @@ -1582,13 +1582,13 @@ "summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity." }, { - "date": "2026-10-06", + "date": "2026-10-13", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 14, - "summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends." + "durationSeconds": 6.47, + "summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The retained send-idempotency suite replaces the removed branch-by-branch refusal matrix." } ], "runtimeBudget": { diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 0de6713cd605..1f32ca7c90f8 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -179,8 +179,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts', diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index d5565f9fdfd7..a762992e23d7 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -39,7 +39,7 @@ export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string - /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + /** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */ abandoned: () => boolean /** Registers the connection so a close during the acquire can stop it. */ track: (connection: AcpStructuredConnection) => void @@ -269,7 +269,7 @@ export async function acquireAcpStructuredSession(input: { session = child assertAcpStartingSession(child, input.abandoned) return { - acquisition: { process, acquisitionGeneration: generation, providerChildPhase: 'starting' }, + acquisition: { process, acquisitionGeneration: generation }, session: child } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 3620304c5cd7..836bfacb08d4 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -6,10 +6,8 @@ export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' import { vi } from 'vitest' import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { - StructuredAgentSessionLifecycleEvent, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { openProviderTimelineRig, SESSION, @@ -56,7 +54,7 @@ export type AcpAdapterRig = { spawned: string[] lifecycle: StructuredAgentSessionLifecycleEvent[] readonly ended: Extract[] - started(): StructuredAgentSessionStartedEvent + started(): StructuredAgentSessionProviderOpenResult openFailure(): unknown settled: Parameters>[0][] acquire(options?: { @@ -89,6 +87,7 @@ export async function openAcpAdapterRig( const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] let openFailure: unknown + let opened: StructuredAgentSessionProviderOpenResult | null = null const startListeners = new Set<() => void>() const spec = options.spec ?? GROK const resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] = @@ -173,16 +172,16 @@ export async function openAcpAdapterRig( return lifecycle.filter((event) => event.type === 'ended') }, started: () => { - const event = lifecycle.findLast((entry) => entry.type === 'started') - if (event?.type !== 'started') { + if (!opened) { throw new Error('ACP session has not started') } - return event + return opened }, openFailure: () => openFailure, settled, acquire: async (acquireOptions = {}) => { openFailure = undefined + opened = null const acquisition = await adapter.acquire({ identity: { sessionId: SESSION, @@ -203,19 +202,12 @@ export async function openAcpAdapterRig( }) if (acquireOptions.waitForStart !== false) { try { - const opened = await adapter.open({ + opened = await adapter.open({ sessionId: SESSION, fence: acquireOptions.fence ?? 1, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, signal: acquireOptions.signal ?? new AbortController().signal }) - lifecycle.push({ - ...opened, - type: 'started', - sessionId: SESSION, - fence: acquireOptions.fence ?? 1, - acquisitionGeneration: acquisition.acquisitionGeneration ?? '' - }) } catch (error) { openFailure = error await adapter.closeSession(SESSION) diff --git a/src/main/acp/acp-structured-missing-model.test.ts b/src/main/acp/acp-structured-missing-model.test.ts index 0aa9dca3876b..15cca2559921 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -2,7 +2,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { replaceAgentSessionRecordOptions } from '../runtime/agent-session-record-options' import { agentSessionStoreDraftRowWrites } from '../runtime/agent-session-store-draft' import type { AgentSessionStoreState } from '../runtime/agent-session-store-state' @@ -45,10 +44,7 @@ function writtenOptions(provider: string, reader: AcpStructuredOptions) { unreadableRecords: new Map(), sessionTabs: null } - // What the host writes from the report a started child sends. - const options = nativeSessionOptionsFromReport({ - reported: reader.read().current - }) + const options = reader.reported() const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -67,7 +63,7 @@ function writtenOptions(provider: string, reader: AcpStructuredOptions) { } describe('ACP sessions without a reported model', () => { - it('writes the captured Windows OMP startup through the live reader and shared normalizer', async () => { + it('writes the captured Windows OMP startup through the provider reader', async () => { const reader = new AcpStructuredOptions() reader.adoptSession(response) expect(reader.read()).toEqual({ diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index 33e04d733eaa..1ff02a264922 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -85,7 +85,7 @@ describe('ACP child published before its handshake', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -146,7 +146,7 @@ describe('ACP child published before its handshake', () => { rig.adapter.open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toMatchObject({ reason: 'notSignedIn' }) diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index b6c895758598..4c5b7174ed05 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -339,7 +339,7 @@ describe('ACP startup that never answers', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -348,7 +348,6 @@ describe('ACP startup that never answers', () => { expect(await opening).toBeInstanceOf(Error) expect(rig.child().exited).toBe(true) expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) - expect(rig.lifecycle.some((event) => event.type === 'started')).toBe(false) }) it('leaves a published child alone when the acquisition signal aborts later', async () => { @@ -368,7 +367,7 @@ describe('ACP startup that never answers', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -392,7 +391,7 @@ describe('ACP startup that never answers', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index c5a05cfb5569..c2f833314f9e 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -58,6 +58,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null return { diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 72d91fc93653..e2768c7c759b 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -17,7 +17,6 @@ import type { StructuredAgentSessionMutationContext } from '../native-chat/agent import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent @@ -401,8 +400,7 @@ describe('Claude effort default at rest', () => { const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') - // The record the start persists names the listed row the catalog learned under. - const record = restingRecord(nativeSessionOptionsFromReport({ reported: reported })) + const record = restingRecord({ model: 'opus[1m]' }) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') expect(record.options).toEqual({ model: 'opus[1m]' }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index bba3eb1a1ee3..80bb0d9a6f96 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -266,7 +266,7 @@ export async function acquireClaudeSession({ ) } // The host calls open after publication and holds input until it settles. - return { ...publication.acquisition, providerChildPhase: 'starting' } + return publication.acquisition } catch (error) { unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a36d490ecc4a..bdec578302cf 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -23,7 +23,7 @@ describe('Claude host-awaited open', () => { const claude = fakeClaude({ initDelayMs: 12_000 }) const { adapter, events } = adapterFor(claude) const acquisition = await adapter.acquire(ACQUIRE) - expect(acquisition).toMatchObject({ providerChildPhase: 'starting', process: { pid: 4321 } }) + expect(acquisition).toMatchObject({ process: { pid: 4321 } }) expect(acquisition.link).toBeUndefined() expect(claude.connections[0].calls).toEqual([]) expect(events.some((event) => event.type === 'options')).toBe(false) @@ -129,7 +129,7 @@ describe('Claude host-awaited open', () => { const open = adapter.open({ sessionId: 'session-1', fence: 7, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, signal: controller.signal }) controller.abort(new Error('host start expired')) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 2068b5d64fe1..2fd4b8b4fccd 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -170,7 +170,7 @@ export type ClaudeSession = { confirmedOptions: Set /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null - /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + /** Fast was omitted from launch settings and needs confirmation at message input. */ fastModeAtStart: boolean /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index d0263b0c100e..ec98636ff014 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -56,6 +56,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null return { diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 22c30ba8f9fb..60c3231ea14d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -1,7 +1,7 @@ // How a durable session record becomes a Codex process launch. // // Every input is read back from the record the store already made durable, not -// from the call that triggered the acquire. A client that attaches twice must +// from the call that triggered spawn. A client that attaches twice must // land in the same working directory under the same account home, and a resume // must name the thread this session actually proved — never one a caller asks // for, which is how a resume becomes a fork wearing a resume's name. diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts deleted file mode 100644 index 4a85cbffa7f1..000000000000 --- a/src/main/codex/codex-structured-question-order.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// A Codex ask with several questions, driven from the real host journal through -// the client's session reducer to the rows the transcript list draws. -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 { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../shared/structured-agent-session-reducer' -import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' -import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' -import { CodexJournalPrompts } from './codex-structured-journal-prompts' -import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' -import type { StructuredAgentSessionAdapter } 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 { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { - projectStructuredQuestionMessages, - structuredQuestionTranscript -} from '../../renderer/src/components/native-chat/structured-agent-question-projection' -import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -type Asked = readonly { id: string; question: string }[] - -// Codex's question ids are the model's own words, so their text order is not -// the order it asked in. These two asks spell the two orders live QA saw. -const ASKED: Asked = [ - { id: 'scope', question: 'Which files are in scope?' }, - { id: 'priority', question: 'What matters most?' }, - { id: 'deadline', question: 'When is it due?' } -] -const ASKED_OUT_OF_ORDER: Asked = [ - { id: 'format', question: 'Which format?' }, - { id: 'audience', question: 'Who reads it?' }, - { id: 'length', question: 'How long?' } -] - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let sink: StructuredAgentSessionEventSink | null -let client: StructuredAgentSessionState -let clock: number - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-')) - resetHostTestOperationIds() - sink = null - clock = HOST_TEST_NOW - const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn(async ({ fence, events }) => { - sink = events ?? null - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - } - }), - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async ({ commit }) => commit()), - setOption: vi.fn(async () => undefined) - } - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - // Every write lands on its own millisecond, as it does live. - now: () => (clock += 1) - }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!page.ok) { - throw new Error('no history page') - } - client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'history-page', - page: page.page - }) - host.subscribe({ - id: 'client', - sessionId: SESSION, - cursor: page.page.liveCursor ?? page.page.window.nextCursor, - emit: (event) => { - client = reduceStructuredAgentSession(client, { type: 'event', event }) - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function codexPrompts(): CodexJournalPrompts { - if (!sink) { - throw new Error('session was never acquired') - } - return new CodexJournalPrompts( - { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, - () => null, - () => 'turn-1' - ) -} - -async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise { - prompts.handle({ - threadId: THREAD, - method: CODEX_USER_INPUT_METHOD, - codexItemId: 'codex-item-1', - promptKey: '7', - params: { - threadId: THREAD, - turnId: 'turn-1', - questions: asked.map((question) => ({ - ...question, - options: [ - { label: 'Yes', description: '' }, - { label: 'No', description: '' } - ] - })) - } - }) - await host.flushStreamedEvents(SESSION) -} - -function questionItem(question: string): AgentJournalRenderItem { - const item = client.items.find( - (candidate) => candidate.body.kind === 'question' && candidate.body.question === question - ) - if (!item || item.body.kind !== 'question') { - throw new Error(`no journal item for ${question}`) - } - return item -} - -async function answer(question: string): Promise { - const item = questionItem(question) - if (item.body.kind !== 'question') { - return - } - const fields = { - itemId: item.itemId, - expectedRevision: item.revision, - optionId: item.body.options[0]!.id - } - const result = await host.respondToPrompt(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.respondTo:question', - sessionId: SESSION, - fields - }) - }, - kind: 'question', - ...fields - }) - expect(result.ok).toBe(true) - await host.flushStreamedEvents(SESSION) -} - -/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */ -function drawnPromptRows(): string[][] { - const { receipts } = structuredQuestionTranscript(client.items) - // The desktop list's projection: its comparator adds only a rank for rows the host never writes. - const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages( - client.items, - [], - client.submissions, - projectStructuredQuestionMessages - ) - ) - return rows.flatMap((row) => { - const prompt = receipts.get(row.id) - if (!prompt || prompt.kind !== 'question') { - return [] - } - const questions = prompt.questions?.length ? prompt.questions : [prompt] - return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)] - }) -} - -describe('a Codex ask with several questions', () => { - it('keeps the pending rest of the ask below the question already answered', async () => { - await ask(codexPrompts()) - await answer(ASKED[0]!.question) - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (pending)', 'When is it due? (pending)'] - ]) - }) - - it('draws every answered question in the order Codex asked it', async () => { - await ask(codexPrompts()) - for (const { question } of ASKED) { - await answer(question) - } - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (resolved)'], - ['When is it due? (resolved)'] - ]) - // Mobile draws the shared projection in journal order, one row per question. - expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( - ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) - ) - ).toEqual(ASKED.map(({ question }) => question)) - }) - - it('draws a cancelled ask in the order Codex asked it', async () => { - const prompts = codexPrompts() - await ask(prompts, ASKED_OUT_OF_ORDER) - prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId) - await host.flushStreamedEvents(SESSION) - - expect(drawnPromptRows()).toEqual([ - ['Which format? (cancelled)'], - ['Who reads it? (cancelled)'], - ['How long? (cancelled)'] - ]) - }) -}) diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index bb822d1cceb9..b73d8c8df380 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -17,7 +17,6 @@ import { import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import type { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' function listing(tier = 'priority') { return { @@ -261,17 +260,4 @@ describe('Codex service tier across clients and rest', () => { expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('serviceTier')).toBe(true) expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('fastMode')).toBe(false) }) - - it('keeps only the reported tier, not the Fast it implies, on the record', () => { - expect( - nativeSessionOptionsFromReport({ - reported: { model: 'gpt-live', serviceTier: 'priority', fastMode: true }, - priorOptions: { fastMode: 'true', serviceTier: 'ultrafast' } - }) - ).toEqual({ model: 'gpt-live', serviceTier: 'priority' }) - // An agent that reports Fast with no tier still keeps it. - expect(nativeSessionOptionsFromReport({ reported: { fastMode: true } })).toEqual({ - fastMode: 'true' - }) - }) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b84971cdf490..872e0a153cc2 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -249,7 +249,7 @@ export async function acquireCodexStructuredSession(input: { }, openInput ) - return { process, acquisitionGeneration, providerChildPhase: 'starting' } + return { process, acquisitionGeneration } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { return closeFailedCodexAcquisition({ diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index c9994a1c45f3..c759a3f08cdb 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -50,7 +50,7 @@ export async function acquireReadyCodexForTest( const result = await adapter.open({ sessionId: input.identity.sessionId, fence: input.fence, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) startReports.get(adapter)?.set(input.identity.sessionId, result) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 30994d89dafe..a5e5d3e26e66 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -18,8 +18,7 @@ import { CodexStructuredSessionTeardown } from './codex-structured-session-teard import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, - readLiveCodexSessionOptions, - readCodexAcquisitionOptions + readLiveCodexSessionOptions } from './codex-structured-session-options' import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { @@ -319,12 +318,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap prepareReadOptions = (input: { sessionId: string; fence: number }) => prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => readCodexAcquisitionOptions(this.session(input.sessionId), input.priorOptions) - closeSession: NonNullable = (sessionId) => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index e9118f111ac1..bbac1d4c3006 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -26,31 +26,6 @@ export function restoredCodexSessionOptions( return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) } -export function readCodexAcquisitionOptions( - session: CodexSession, - priorOptions: Readonly> | undefined -): Readonly> | undefined { - const options = { - ...Object.fromEntries( - Object.entries(priorOptions ?? {}).filter(([key]) => !isCodexTurnOptionKey(key)) - ), - ...Object.fromEntries(session.options) - } - const reported = session.reportedOptions - // The thread's effort belongs to the thread's model, not to a different saved one. - if ( - options.effort === undefined && - reported.effort && - (options.model === undefined || options.model === reported.model) - ) { - options.effort = reported.effort - } - if (options.model === undefined && reported.model) { - options.model = reported.model - } - return Object.keys(options).length > 0 ? options : undefined -} - /** Startup reports the thread's defaults together with the saved next-turn intent. */ export function reportedCodexSessionOptions( session: CodexSession diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 5bea0c49a235..87138afd2add 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -70,7 +70,6 @@ let idleMs: number let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { @@ -102,7 +101,12 @@ async function startHost(): Promise { cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), - ...adapterExtras + ...adapterExtras, + open: + adapterExtras.open ?? + (async () => ({ + reportedOptions: { model: '' } + })) }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', @@ -236,16 +240,6 @@ function framedStates(events: AgentSessionSubscribeEvent[], id: string): string[ ) } -function deferred() { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((next, fail) => { - resolve = next - reject = fail - }) - return { promise, resolve, reject } -} - /** Rows written by an earlier host process that ended before handing them over. */ async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise @@ -281,13 +275,13 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) }) - const answering = deferred() + const answering = Promise.withResolvers() const answer = dispatch.getMockImplementation()! dispatch.mockImplementationOnce(async (input) => { await answering.promise @@ -309,7 +303,7 @@ describe('a send is answered at acceptance', () => { it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) @@ -605,8 +599,8 @@ describe('what an earlier host process left behind', () => { }) }) -describe('a child that exits before its message is handed over', () => { - it('rejects the message with the exit reason instead of starting another child (W24)', async () => { +describe('a child that exits before open completes', () => { + it('rejects the message with the start failure instead of starting another child (W24)', async () => { await host.close(SESSION, 'evict') await startHost() // The child exits before the host can finish its protocol open. @@ -715,7 +709,7 @@ describe('Stop withdraws what is queued', () => { it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) @@ -758,7 +752,7 @@ describe('Stop withdraws what is queued', () => { describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => { @@ -786,7 +780,7 @@ describe('an eviction between acceptance and handover', () => { const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) // Held, the second send is accepted and the close runs next, ahead of its handover. - const second = deferred() + const second = Promise.withResolvers() void host['tasks'].serialize(SESSION, () => second.promise) const accepting = accept('still queued') const closing = host.close(SESSION, 'evict') @@ -804,7 +798,7 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 1_000 - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => { @@ -838,7 +832,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { it.each([{ end: 'evict', starting: true, kind: 'chatClosed', verdict: null }] as const)( 'rejects what is queued as $kind after a $end (during startup: $starting)', async ({ end, starting, kind, verdict }) => { - const started = deferred() + const started = Promise.withResolvers() adapterExtras = { // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { 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 98ac767a664d..9aef5be41d65 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 @@ -8,7 +8,7 @@ export class StructuredAgentSessionAcquireAborts { /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null - /** For the attach about to run; `end` once it settles. */ + /** For the provider wait about to run; `end` once it settles. */ begin(sessionId: string, handover = false): { signal: AbortSignal; end: () => void } { const controller = new AbortController() if (this.quitReason) { @@ -40,7 +40,7 @@ export class StructuredAgentSessionAcquireAborts { return stoppedHandover } - /** Quit: every start under way stops, and so does any the attach drain still runs. */ + /** Quit: every provider wait under way stops, including those queued in the attach drain. */ abortAll(reason: string): void { this.quitReason ??= new Error(reason) for (const waits of this.inFlight.values()) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts deleted file mode 100644 index 6da0e33acc0d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ /dev/null @@ -1,657 +0,0 @@ -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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'legacy-session' -const CREATE_OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -const RESUME_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams( - operationId: string, - expectedRuntimeFence: number | null, - options?: Readonly> -): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - ...(options ? { options } : {}), - providerHandle: { kind: 'codex', threadId: 'legacy-thread' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -function adapter(input: { - origin: 'created' | 'resumed' - options?: AgentSessionOptionsResult -}): StructuredAgentSessionAdapter { - return { - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `${input.origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin: input.origin, - mintedAtFence: fence, - observedAt: NOW - } - })), - ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -function expectSettledAttachLease(record: AgentSessionRecord | null): void { - expect(record).not.toBeNull() - const lease = record!.lease - const durableState = lease.handoffStage ?? lease.claimStatus - expect(['live', 'released', 'recovering']).toContain(durableState) - expect(lease.handoffStage).not.toBe('new-owner-proving') -} - -describe('structured session acquisition options', () => { - it('samples provider history before acquiring a replacement child', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(root) - let childAcquired = false - const historyWindow = (): ProviderHistoryWindow => ({ - items: [], - boundaryConsistent: true, - turnInFlight: childAcquired - }) - const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { - const sessionAdapter = adapter({ origin }) - const acquire = vi.mocked(sessionAdapter.acquire) - acquire.mockImplementation(async (input) => { - childAcquired = true - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `${origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }) - sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) - return sessionAdapter - } - - let firstJournal: AgentSessionJournal | undefined - const first = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: initialStore, - adapter: withHistory('created'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: (attached) => { - firstJournal = attached.journal - } - }) - expect(first).toMatchObject({ ok: true }) - await firstJournal!.appendSubmission({ - clientMessageId: 'crashed-send', - payloadFingerprint: digestPayload('deploy the thing'), - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'deploy the thing' }] - } satisfies AgentJournalMessageItem, - fence: 1 - }) - await firstJournal!.close() - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - childAcquired = false - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - const second = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: withHistory('resumed'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) - expect(second).toMatchObject({ - value: { - page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } - } - }) - }) - - it('persists create defaults before the first provider acquisition', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } - const recordPhase = vi.fn() - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - recordPhase, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.acquire).toHaveBeenCalledWith( - expect.objectContaining({ options, recordPhase }) - ) - expect(store.getRecord(SESSION)?.options).toEqual(options) - }) - - it('replays a create retried after the host re-resolved different options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const attempt = async (options: Readonly>, spawnToken: string) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a') - // Why: the user may reselect a model between an unknown-outcome create and the - // retry that reuses its operation id; the retry must replay, not conflict. - const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b') - - expect(created).toMatchObject({ ok: true }) - expect(retried).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) - }) - - it('preserves saved next-message choices when a provider reports its live options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toBeUndefined() - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - options: { approvalPolicy: 'on-request', personality: 'concise', fastMode: 'true' }, - now: NOW - }) - - const resumedStore = await openTestAgentSessionRecordStore(root) - await resumedStore.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const resumed = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: resumedStore, - adapter: adapter({ - origin: 'resumed', - options: { - current: { model: 'gpt-5.6-terra', effort: 'medium', fastMode: false }, - models: [] - } - }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(root) - expect(reopened.getRecord(SESSION)?.options).toEqual({ - approvalPolicy: 'on-request', - personality: 'concise', - fastMode: 'true' - }) - }) - - it('keeps a saved Fast choice without an acquisition report', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ - origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] } - }) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { - model: 'gpt-standard', - fastMode: 'true' - }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard', fastMode: 'true' }) - }) - - it('proves an opened Codex owner without replacing saved choices from readback', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => ({ model: 'gpt-reported', effort: 'high' })) - } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(sessionAdapter.readAcquisitionOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-saved', effort: 'medium' }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('retains saved options when an opened Codex thread reports no effective model', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-saved-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => undefined) - } - const options = { model: 'gpt-saved', effort: 'medium' } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual(options) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - }) - - describe.each([ - ['adapter acquire', 'acquire'], - ['identity commit', 'identity'], - ['owner proof', 'proof'], - ['journal attach', 'journal'] - ] as const)('%s failure', (_label, failurePoint) => { - it.each([ - ['proven cleanup', true], - ['unproven cleanup', false], - ['cleanup error', 'throws'] - ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { - const exitProven = cleanup === true - root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const store = await openTestAgentSessionRecordStore(root) - const base = adapter({ - origin: 'created', - options: { current: { model: 'gpt-5.6-terra' }, models: [] } - }) - const injected = new Error(`${failurePoint} failed`) - const acquire = vi.mocked(base.acquire) - if (failurePoint === 'acquire') { - acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'identity') { - vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) - } else if (failurePoint === 'proof') { - vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) - } else if (failurePoint === 'journal') { - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle('legacy-thread'), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - } - const releaseAcquisition = vi.fn(async () => { - if (cleanup === 'throws') { - throw new Error('cleanup failed') - } - return cleanup - }) - const failingAdapter = { ...base, acquire, releaseAcquisition } - let journalFailure = failurePoint === 'journal' ? injected : null - const perform = ( - target: AgentSessionRecordStore, - operationId: string, - fence: number | null - ) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: target, - adapter: failingAdapter, - openConversation: async (record) => { - const failure = journalFailure - journalFailure = null - if (failure) { - throw failure - } - return openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - }, - authority: { - spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(operationId, fence), - now: () => NOW, - onAttached: () => {} - }) - - // A proven exit before the journal opens is answered once, as the refusal its replay gives; - // no exit was observed, so it names no situation. - const failed = perform(store, CREATE_OPERATION, null) - await (exitProven && failurePoint !== 'journal' - ? expect(failed).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - : expect(failed).rejects.toThrow( - exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' - )) - - const reopened = await openTestAgentSessionRecordStore(root) - const failedRecord = reopened.getRecord(SESSION) - expectSettledAttachLease(failedRecord) - expect( - reopened.listOperationRows().find((row) => row.operationId === CREATE_OPERATION)?.outcome - ).toMatchObject({ status: 'failed' }) - - await reopened.reconcileOnRestart({ - probe: async (record) => - exitProven || record.lease.ownerProcess === null - ? exitProven - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'owner identity was never committed' } - : { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, - now: NOW + 1 - }) - - if (exitProven) { - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - expectSettledAttachLease(reopened.getRecord(SESSION)) - } else if (failurePoint === 'proof' || failurePoint === 'journal') { - // A recorded owner goes to recovery, which concludes about it before the next start. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', - handoffStage: 'recovering', - // The settled operation must not stay named by the lease as an in-flight transfer. - handoffOperationId: null, - reservedSpawnToken: 'spawn-a' - }) - await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - } else { - // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, - // with no death evidence, since nothing proved one. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null, - deathEvidence: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - } - }) - }) -}) - -describe('the tab a create reserves', () => { - async function openStore() { - root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(root) - } - - function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { - return performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which - // conversation this attaches to. - params: { - ...attachParams(CREATE_OPERATION, null), - ...(surfaceTabId ? { surfaceTabId } : {}) - }, - now: () => NOW, - onAttached: () => {} - }) - } - - it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { - const store = await openStore() - const created = await attachWith(store, 'chat-tab-1') - // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. - expect(created.ok && created.value.tabId).toBeUndefined() - expect(store.getSessionTabId(SESSION)).toBeNull() - - await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') - expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ - ok: true, - value: { tabId: 'chat-tab-1' } - }) - expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts deleted file mode 100644 index 90c94306db1d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is -// recorded beside its identity, and the start compares the process, not the stamp. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'stamped-owner-session' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -it('starts a chat whose recorded owner carries the runtime stamp', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-')) - const store = await openTestAgentSessionRecordStore(root) - // The record as the store published it, stamp included, rather than the transition's own copy. - const commit = store.commitProcessIdentity.bind(store) - vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => { - await commit(input) - const published = store.getRecord(input.sessionId) - if (!published) { - throw new Error('the committed record is missing') - } - return published - }) - const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn( - async ({ fence, spawnToken, onSpawned }) => { - const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } - await onSpawned?.(process) - return { - process, - link: { - linkId: 'created-link', - handle: codexProviderHandle('thread-1'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - } - ), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - - const attached = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(), - now: () => NOW, - onAttached: () => {} - }) - - expect(attached).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({ - pid: 4242, - runtime: agentSessionRuntimeIncarnation() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index d239c2c0c599..5ef3064a0d21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -5,14 +5,12 @@ import type { } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, - isAgentSessionPreSpawnError, - type StructuredAgentSessionProviderChildPhase + isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp * is about who holds the process, not which process it is. */ @@ -25,23 +23,6 @@ function sameOwnerProcess( return isDeepStrictEqual(storedProcess, acquiredProcess) } -/** Bookkeeping: a failure is logged, never the proven start's. */ -function handOverStartCatalogListing( - input: AttachFlowInput, - sessionId: string, - listing: AgentModelCatalogLiveListing -): void { - try { - input.onStartCatalogListing?.(listing) - } catch (error) { - input.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', - sessionId, - error - }) - } -} - /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ export async function acquireOwner( @@ -49,8 +30,7 @@ export async function acquireOwner( record: AgentSessionRecord ): Promise<{ record: AgentSessionRecord - acquisitionGeneration: string | null - providerChildPhase: StructuredAgentSessionProviderChildPhase + acquisitionGeneration: string }> { const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken @@ -91,7 +71,6 @@ export async function acquireOwner( }) } }) - const providerChildPhase = acquired.providerChildPhase ?? 'ready' if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -102,20 +81,14 @@ export async function acquireOwner( } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } + // The process owns the lease now; a handle the provider answers with is returned by `open`. const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), now: input.now() }) - if (providerChildPhase === 'ready' && acquired.catalogListing) { - handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) - } - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - providerChildPhase - } + return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration } } catch (error) { if (isAgentSessionPreSpawnError(error)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts deleted file mode 100644 index 6ab54a89eec0..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { - AgentSessionAcquisition, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentDefinition } from './structured-agent-definition' - -const LOCAL: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} - -function identity(sessionId: string, agent: string): AgentSessionJournalIdentity { - return { - sessionId, - workspaceId: 'workspace-1', - hostId: 'local', - agent, - 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 - } - } -} - -function fakeAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - ...overrides - } -} - -/** An agent this build does not ship, declared the way a new adapter would declare itself. */ -const PILOT: StructuredAgentDefinition = { - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - rewind: false, - compact: false, - threadGoal: false, - contextUsage: false, - imagePrompts: false, - steering: 'queue', - approvalEnforcement: 'orca' - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -/** A double with every method Claude's and Codex's declarations need. */ -function declaringAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return fakeAdapter({ - compact: vi.fn(), - changeThreadGoal: vi.fn(), - rewind: vi.fn(), - recoverRewind: vi.fn(), - ...overrides - }) -} - -function claudeAndCodex( - adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {} -): StructuredAgentRegistry { - return new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() }, - { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() } - ]) -} - -describe('StructuredAgentSessionAdapterRouter registry', () => { - it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { - const pilot = fakeAdapter() - const router = new StructuredAgentSessionAdapterRouter( - new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]), - async () => {} - ) - - expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) - expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) - await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) - expect(pilot.acquire).toHaveBeenCalledOnce() - await expect( - router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) - ).rejects.toThrow('structured sessions do not support claude') - }) - - it('refuses two registrations for one agent', () => { - expect( - () => - new StructuredAgentRegistry([ - { definition: PILOT, adapter: fakeAdapter() }, - { definition: PILOT, adapter: fakeAdapter() } - ]) - ).toThrow('structured agent grok is registered twice') - }) - - it.each([ - ['compact', 'compact'], - ['threadGoal', 'changeThreadGoal'], - ['rewind', 'rewind'], - ['rewind', 'recoverRewind'] - ] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => { - const declared: StructuredAgentDefinition = { - ...PILOT, - capabilities: { ...PILOT.capabilities, [capability]: true } - } - const adapter = declaringAdapter({ [method]: undefined }) - - expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow( - `structured agent grok declares a capability its adapter has no ${method} for` - ) - expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow() - }) - - it('answers what each registered agent declares, and nothing for an unregistered one', () => { - const agents = claudeAndCodex() - - expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('grok')).toBeNull() - expect(agents.definition('grok')).toBeNull() - expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) - }) - - it('lets a session narrow a declared rewind but never widen an undeclared one', async () => { - const narrowing = declaringAdapter({ - rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) - }) - const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) - const router = new StructuredAgentSessionAdapterRouter( - claudeAndCodex({ codex: narrowing, claude: widening }), - async () => {} - ) - const unsupported = { supported: false, reason: 'unsupported' } - - expect(router.rewindSupport('session-1', 'codex')).toEqual({ - supported: false, - reason: 'history-not-paginated' - }) - // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. - expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) - expect(router.rewindSupport('session-1')).toEqual(unsupported) - expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) - }) -}) - -describe('structured agent definitions', () => { - it('declares what Claude and Codex structured chats already did', () => { - expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: false, - compact: true, - threadGoal: false, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: true, - compact: true, - threadGoal: true, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - }) - - it('keeps each agent’s resting option rules with its definition', () => { - const claude = CLAUDE_STRUCTURED_AGENT.restingOptions - const codex = CODEX_STRUCTURED_AGENT.restingOptions - expect(claude.fallbackModels()?.length).toBeGreaterThan(0) - expect(codex.fallbackModels()).toBeNull() - expect(claude.effortDefaultsToModel).toBe(true) - expect(codex.effortDefaultsToModel).toBe(false) - expect(claude.acceptsKey('model')).toBe(true) - expect(codex.acceptsKey('model')).toBe(true) - expect(claude.acceptsKey('no-such-option')).toBe(false) - }) -}) 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 4448d12c87c2..54145725fa0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,4 +1,3 @@ -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { @@ -68,13 +67,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return released } - open: NonNullable = (input) => { - const adapter = this.owner(input.sessionId) - if (!adapter.open) { - return Promise.reject(new Error('The provider cannot open a published child.')) - } - return adapter.open(input) - } + open: StructuredAgentSessionAdapter['open'] = (input) => this.owner(input.sessionId).open(input) dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) @@ -165,9 +158,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) - startAnswered = (sessionId: string): boolean | undefined => - this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) - prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) @@ -179,17 +169,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return reader(input) } - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => { - const adapter = this.owner(input.sessionId) - return adapter.readAcquisitionOptions - ? adapter.readAcquisitionOptions(input) - : readNativeSessionOptions({ adapter, ...input }) - } - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts deleted file mode 100644 index daa7d2028428..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError -} from './structured-agent-session-adapter' -import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' - -describe('failed agent-session acquisition cleanup', () => { - it('names a failure exit-proven after proven cleanup, keeping its diagnostic and cause', async () => { - const cause = new Error('proof failed') - const thrown = await rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ).catch((error: unknown) => error) - - expect(thrown).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) - expect(thrown).toMatchObject({ message: 'proof failed', cause }) - }) - - it.each([ - ['a refusal', new AgentSessionAcquisitionRefusal('not signed in')], - ['a root exit', new AgentSessionAcquisitionRootExitObservedError(new Error('exited'))], - ['a host store code', new Error('agent_session_checkpoint_stale')] - ])('keeps %s that already names its verdict after proven cleanup', async (_label, cause) => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ) - ).rejects.toBe(cause) - }) - - it('reports unproven exit when cleanup returns false', async () => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => false) }, - 'session-1', - new Error('proof failed') - ) - ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - }) - - it('keeps a first-hand root exit that cleanup observed, with the provider diagnostic', async () => { - const cause = new Error('proof failed') - const exit = new AgentSessionAcquisitionRootExitObservedError( - new Error('claude stream-json exited (code 1): crashed') - ) - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw exit - }) - }, - 'session-1', - cause - ).catch((thrown: unknown) => thrown) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') - expect((error as Error).cause).toMatchObject({ errors: [cause, exit] }) - }) - - it('reports unproven exit when cleanup throws', async () => { - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw new Error('cleanup failed') - }) - }, - 'session-1', - new Error('proof failed') - ).catch((cause: unknown) => cause) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).toMatchObject({ cause: expect.any(AggregateError) }) - }) -}) 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 8a6d1e3f03ac..82733233c4d1 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 @@ -54,7 +54,10 @@ import type { ProviderHistoryWindow } from '../agent-session-journal/journal-sub import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' -import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' /** A live options read, plus what the child listed for its account's saved catalog. The host * saves that listing and strips it before answering; an adapter whose listing reaches the @@ -104,22 +107,14 @@ export { AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-acquisition-errors' -/** What a reservation turns into once something is actually running under it: - * the process the host can probe, and the provider handle it was minted with. */ +/** The owned process returned before protocol construction begins. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity + /** Only a handle the adapter already holds at spawn; protocol handles come from open. */ link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ - acquisitionGeneration?: string - /** `starting`: published at spawn, proven later by a `started` event. Absent means `ready`, an - * acquire that ran the whole handshake before answering; that bridge ends once every adapter - * publishes at spawn. Either way the host holds input until the child is `ready`. */ - providerChildPhase?: StructuredAgentSessionProviderChildPhase - /** What a `ready` child listed at its start, with the configured default its start resolved; - * the host saves it once. A `starting` child (Claude) hands its listing on `started` and its - * resolved default on the settings readback's `options-reported`. */ - catalogListing?: AgentModelCatalogLiveListing + acquisitionGeneration: string } /** A refusal before spawn that a person can act on; the site that refused names it. */ @@ -212,37 +207,20 @@ export type StructuredAgentSessionEndedEvent = { startupUnanswered?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied. - * What it reports from here on is fact, not a catalog guess. */ -export type StructuredAgentSessionStartedEvent = { - /** The handle supplied when the provider finishes starting. */ - link?: AgentSessionProviderHandleLink - type: 'started' - sessionId: string - fence: number - acquisitionGeneration: string - /** What the child proved, snapshotted by the adapter from what startup already read. The host - * handles this inside the session's serialized step, so it must not ask the CLI. */ - reportedOptions: AgentSessionOptionsResult['current'] - /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ - catalogListing?: AgentModelCatalogLiveListing -} - -/** What a child that already proved its start reports later, such as an optional read that came - * after `started`: persisted as the start's report is. Never delays a start. */ -export type StructuredAgentSessionOptionsReportedEvent = Omit< - StructuredAgentSessionStartedEvent, - 'type' | 'link' -> & { type: 'options-reported' } +/** An optional report after open; it never delays readiness. */ +export type StructuredAgentSessionOptionsReportedEvent = + StructuredAgentSessionProviderOpenResult & { + type: 'options-reported' + sessionId: string + fence: number + acquisitionGeneration: string + } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent - | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent -/** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child, which the host gives no input until the `started` - * lifecycle event flips it. */ +/** Whether the host's open has proven the child ready for input. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' /** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always @@ -270,7 +248,7 @@ export type StructuredAgentSessionSetOptionInput = { export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { /** Opens the published process under the host's startup signal. */ - open?: StructuredAgentSessionProviderOpen + open: StructuredAgentSessionProviderOpen /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ @@ -394,9 +372,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> - /** False while the live child has not answered its start, so it has run nothing it was handed. - * Absent or undefined reads as answered. */ - startAnswered?(sessionId: string): boolean | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string @@ -406,15 +381,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise - /** Effective options already known after acquisition, without discovering picker choices. */ - readAcquisitionOptions?(input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }): - | Promise> | undefined> - | Readonly> - | undefined /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 7b478ff10a54..859b3055cdb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -106,9 +106,13 @@ function attachParams( function adapter(): StructuredAgentSessionAdapter { return { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', @@ -184,6 +188,7 @@ describe('adopting a provider conversation on create', () => { const handle = { transport: 'acp', agent: 'grok', nativeId: THREAD } const sessionAdapter = adapter() vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index b8ebf78b9370..7abddf6130be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -117,7 +117,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -134,6 +133,7 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: () => new Promise(() => {}), acquire, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts deleted file mode 100644 index 6a54478a4613..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -// A Claude chat whose Claude is not running offers the `/` commands and skills Claude would read -// from its folders on the host that runs it: after a /clear, after a relaunch, as files change. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionSlashCommand, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { - CLAUDE_AT_REST_COMMANDS_TTL_MS, - ClaudeAtRestCommandCatalog -} from '../../claude/claude-at-rest-commands' -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 { StructuredAgentSessionHost } from './structured-agent-session-host' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const caller = { callerKey: 'desktop' } -const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' - -let directory: string -let workspace: string -let account: string -let clock: number -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost - -/** Claude is never running here, so the only `/` surface is the one read from its folders. */ -function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - supportsCreate: () => true, - acquire: vi.fn(async (input) => ({ - process: { - hostId: 'local', - pid: 4001, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `link-${input.fence}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: 'created' as const, - handle: claudeProviderHandle(CLAUDE_SESSION, null) - } - })), - atRestCommands: catalog, - dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => {}, - setOption: async () => {}, - releaseAcquisition: async () => true, - closeSession: async () => true - } -} - -function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { - return new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspacePath, - now: () => clock, - // Only the folders this test writes, never the machine's own home. - discover: async (args) => { - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) -} - -async function openHost(catalog = catalogFor(workspace)): Promise { - store = await openTestAgentSessionRecordStore(directory) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - store, - adapter: adapter(catalog), - journalDatabase: openTestJournalHostDatabase(directory), - claimKeyId: 'key', - logger: recordingStructuredAgentSessionLogger().logger, - now: () => clock, - probeOwner: async () => ({ outcome: 'pid-absent' }) - }) -} - -async function writeSkill(root: string, name: string): Promise { - await mkdir(join(root, name), { recursive: true }) - await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: A skill\n---\n`) -} - -async function writeCommand(root: string, file: string): Promise { - await mkdir(join(root, file, '..'), { recursive: true }) - await writeFile(join(root, file), '---\ndescription: A command\n---\nDo it.\n') -} - -/** The `/` surface a pane subscribed to `sessionId` shows once the scan has landed. */ -async function menuOf(sessionId: string): Promise { - let commands: AgentSessionSlashCommand[] | null | undefined - const emit = (event: AgentSessionSubscribeEvent) => { - if ('commands' in event) { - commands = event.commands - } - } - const unsubscribe = await host.subscribe({ id: `pane-${sessionId}`, sessionId, emit }) - await vi.waitFor(() => expect(commands).toBeTruthy()) - unsubscribe() - return commands ?? null -} - -const namesOf = (menu: AgentSessionSlashCommand[] | null, kind: 'skill' | 'command') => - (menu ?? []).filter((entry) => entry.kind === kind).map((entry) => entry.name) - -beforeEach(async () => { - resetHostTestOperationIds() - clock = HOST_TEST_NOW - directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-commands-')) - workspace = join(directory, 'workspace') - account = join(directory, 'claude-account') - await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr') - await writeCommand(join(account, 'commands'), 'deploy.md') - await openHost() - const attached = await host.attach( - caller, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: account }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - await host.setSessionTabVisibility(SESSION, true) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(directory, { recursive: true, force: true }) -}) - -describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => { - it('after /clear, before the fresh agent context starts', async () => { - const cleared = await host.conversationCommand(caller, { - command: 'clear', - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: SESSION, - fields: { command: 'clear' } - }) - } - }) - expect(cleared).toMatchObject({ ok: true }) - const replacement = SESSION - const menu = await menuOf(replacement) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - // Everything the menu offered before a list existed, then what the folders add. - expect(namesOf(menu, 'command')).toEqual(['model', 'effort', 'clear', 'compact', 'deploy']) - }) - - it('keeps a slow scan once it answers, never a staler one after a newer, and runs at most two', async () => { - const pending: ((skills: string[]) => void)[] = [] - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: () => - new Promise((resolve) => { - pending.push((names) => - resolve({ - skills: names.map((name) => ({ - id: name, - name, - description: null, - providers: ['claude'], - sourceKind: 'repo', - sourceLabel: 'repo', - rootPath: workspace, - directoryPath: workspace, - skillFilePath: workspace, - installed: true, - updatedAt: null - })), - sources: [], - scannedAt: clock - }) - ) - }) - }) - const record = store.getRecord(SESSION)! - const skills = () => namesOf(catalog.read(record) ?? null, 'skill') - const changed = vi.fn() - catalog.onChange(changed) - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(1)) - // Overdue: a second scan starts beside the first; a third never does while both run. - clock += 30_000 - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(2)) - clock += 30_000 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(pending).toHaveLength(2) - // The second answers first; the first, older one landing after it changes nothing. - pending[1](['newer']) - await vi.waitFor(() => expect(skills()).toEqual(['newer'])) - pending[0](['older']) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(skills()).toEqual(['newer']) - expect(changed).toHaveBeenCalledTimes(1) - // A slow scan is not thrown away: the next one, however long it takes, is kept. - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(3)) - clock += 35_000 - pending[2](['slow']) - await vi.waitFor(() => expect(skills()).toEqual(['slow'])) - }) - - it('waits out the window from when a scan lands, even one that failed', async () => { - let calls = 0 - let failNext = true - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: async (args) => { - calls += 1 - // Each scan takes 15 s of the clock, longer than the window. - clock += 15_000 - if (failNext) { - failNext = false - throw new Error('unreadable') - } - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) - const record = store.getRecord(SESSION)! - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - catalog.read(record) - await vi.waitFor(() => expect(warned).toHaveBeenCalledOnce()) - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(1) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => - expect(namesOf(catalog.read(record) ?? null, 'skill')).toEqual(['review-pr']) - ) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - 1 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(2) - } finally { - warned.mockRestore() - } - }) - - it('after a relaunch, with nothing remembered from before it', async () => { - await host.flushAllStreamedEvents() - await openHost() - const menu = await menuOf(SESSION) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - expect(namesOf(menu, 'command')).toContain('deploy') - }) - - it('with a command added on disk, pushed to a pane already showing the chat', async () => { - let commands: AgentSessionSlashCommand[] | null | undefined - await host.subscribe({ - id: 'pane', - sessionId: SESSION, - emit: (event) => { - if ('commands' in event) { - commands = event.commands - } - } - }) - await vi.waitFor(() => expect(namesOf(commands ?? null, 'command')).toContain('deploy')) - await writeCommand(join(workspace, '.claude', 'commands'), 'frontend/test.md') - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - // Anything that reads the surface after the window scans again; the pane is told the answer. - host.readCommands(SESSION) - await vi.waitFor(() => - expect(namesOf(commands ?? null, 'command')).toEqual( - expect.arrayContaining(['deploy', 'frontend:test']) - ) - ) - }) - - it('from the folders of the host that runs it, never another host’s', async () => { - const remoteWorkspace = join(directory, 'remote-workspace') - await writeSkill(join(remoteWorkspace, '.claude', 'skills'), 'remote-only') - const remote = catalogFor(remoteWorkspace) - const record = store.getRecord(SESSION)! - remote.read(record) - await vi.waitFor(() => - expect(namesOf(remote.read(record) ?? null, 'skill')).toEqual(['remote-only']) - ) - // A chat this host does not run is answered by nothing here, not by this host's folders. - const local = catalogFor(workspace) - const elsewhere = { - ...record, - location: { ...HOST_TEST_LOCATION, executionHostId: 'ssh:box' as const } - } - local.read(elsewhere) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(local.read(elsewhere)).toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 3f49fbdd0c23..0d192a1661fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,10 +5,7 @@ import { failedAcquisitionSettlement, preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' // The host supplies owner authority; this flow reserves, proves, and publishes the session. import type { @@ -52,7 +49,6 @@ import type { StructuredAgentSessionStartupAttempt } from './structured-agent-se import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' export type AttachedOwner = 'acquired' | 'kept' | 'none' @@ -74,20 +70,17 @@ export type AttachFlowInput = { onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress - /** A completed acquisition's start listing. */ - onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void startupOptions?: Readonly> /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( record: AgentSessionRecord ) => Promise /** Publishes the journal before clients can send against the new owner. `owner` says whether - * this attach spawned the child, kept a live one, or left the chat at rest. */ + * this attach spawned the child (then `starting`), kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - owner: AttachedOwner, - providerChildPhase: StructuredAgentSessionProviderChildPhase + owner: AttachedOwner ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink @@ -137,7 +130,6 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null let owner: AttachedOwner = 'kept' - let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false @@ -213,7 +205,6 @@ export async function performAttach( ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration - providerChildPhase = acquired.providerChildPhase owner = 'acquired' } } catch (error) { @@ -275,7 +266,7 @@ export async function performAttach( providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, owner, providerChildPhase) + await input.onAttached(attached, acquisitionGeneration, owner) await store.recordOperationOutcome({ callerKey: input.callerKey, operationId: params.envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index bde49559a843..2a6bcb7570c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -200,8 +200,6 @@ async function runAttachUnderAbort( now: () => context.now(), recordPhase, acquireSignal, - onStartCatalogListing: (listing) => - context.deps.modelCatalog?.recordLiveListing(sessionId, listing), onStartupAttempt: (startup) => { attempt.startup = startup return startupAttempts.track(sessionId, startup) @@ -220,7 +218,7 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), - onAttached: async (attached, acquisitionGeneration, owner, providerChildPhase) => { + onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor @@ -254,15 +252,15 @@ async function runAttachUnderAbort( child: { generation: acquisitionGeneration ?? current?.generation ?? null, fence, - // An acquire reports its phase; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), + // A spawned child starts unready; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), ...(permissionMode === undefined ? {} : { permissionMode }) } } } // Only a proven child has the protocol session recovery asks; a starting one recovers in - // its `started` step, before it is handed anything. + // its open step, before it is handed anything. if (attempt.candidate?.child.phase === 'ready') { await recoverStructuredRewind( context.deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 2ea3a16024be..50a341d8b33f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -112,7 +112,7 @@ describe('Claude root-exit stop', () => { journal, params, child: { - generation: acquisition.acquisitionGeneration ?? null, + generation: acquisition.acquisitionGeneration, fence, phase: 'ready' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts deleted file mode 100644 index 53dcb9d420f7..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Stopping an agent and closing its conversation's handle are two things, and a reader, a session -// list and a send each see only the one that happened. Ticks are driven by hand. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { setStructuredAgentSessionHost } from './structured-agent-session-registry' -import { - collectSubscriber, - createRestTestRig, - foundRestTestChat, - IDLE_MS, - readerSaw, - REST_TEST_CALLER as CALLER, - REST_TEST_SESSION as SESSION, - restTestSend, - sweepOnce, - type RestTestRig -} from './structured-agent-session-rest-test-rig' -import { hostTestAttachParams } from './structured-agent-session-host-test-data' - -let rig: RestTestRig - -const COLD_START = { timeout: 10_000 } - -beforeEach(async () => { - rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) - setStructuredAgentSessionHost(rig.host) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await rig.dispose() -}) - -function fence(): number { - return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 -} - -function openSession(): StructuredAgentSessionHostSession | undefined { - return rig.host.collaboratorsForTests().sessions.get(SESSION) -} - -function statusRows(): AgentSessionStatusSummary[] { - return rig.statusEvents - .flatMap((event) => (event.type === 'status' ? [event.session] : [])) - .filter((summary) => summary.sessionId === SESSION) -} - -describe('a stop that fails', () => { - it('is retried on the next tick (P2-11 a)', async () => { - await foundRestTestChat(rig) - rig.adapter.closeSession.mockResolvedValueOnce(false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - // The child stays on record with its close unproven, and the next tick joins it. - expect(openSession()?.child?.close).toMatchObject({ cause: 'evict' }) - await sweepOnce(rig.host) - - expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - }) - - it('re-derives a release its wind-down could not write before the handle closes (P2-11 b)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - // The child is proven gone and its work settled, then handing the lease back fails once. - const transition = vi - .spyOn(rig.store, 'transitionHandoff') - .mockRejectedValueOnce(new Error('store unavailable')) - - await sweepOnce(rig.host) - - // Reported, never retried as a stop: the handle's close writes the release from the proof. - expect(transition).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(rig.adapter.closeSession).toHaveBeenCalledOnce() - expect(rig.host.hasSession(SESSION)).toBe(false) - }) -}) - -describe('closing the handle', () => { - it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { - await foundRestTestChat(rig) - const reader = collectSubscriber() - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - // The row stays in the agent-status store, idle and no longer running here, and a session - // list that connects now still gets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] - rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) - expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) - expect(reader.events.some((event) => event.type === 'end')).toBe(false) - - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - // The reader opened on the first handle is fed by the second. - await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) - }) - - it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { - await foundRestTestChat(rig) - await rig.store.setSessionTabVisibility(SESSION, false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - await sweepOnce(rig.host) - expect(rig.sink.forget).not.toHaveBeenCalled() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { - await foundRestTestChat(rig) - - await rig.host.close(SESSION, 'evict') - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - // The stop says not-running; the row belongs to the tab, so nothing forgets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalledOnce() - }) - - it('never hands a reader a handle it is closing (P2-25)', async () => { - await foundRestTestChat(rig) - const journal = openSession()?.journal - if (!journal) { - throw new Error('the conversation should be open') - } - const closing = Promise.withResolvers() - const gate = Promise.withResolvers() - const close = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - await close() - closing.resolve() - await gate.promise - }) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - await closing.promise - - const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) - const facts = readStructuredSessionGateFacts(SESSION) - gate.resolve() - await tick - - const page = await history - expect(page.ok && page.page.items.length).toBeGreaterThan(0) - await expect(facts).resolves.not.toBeNull() - // And the handle a write finds is a live one. - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - }) - - it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - const within = (work: Promise) => - Promise.race([ - work, - new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) - ]) - const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) - - await within(tick) - expect(await sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() - }) -}) - -describe('the sweep and the lease (P2-20)', () => { - it('reads only open conversations and never probes or resolves a lease', async () => { - await foundRestTestChat(rig) - const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) - await rig.restart({ probeOwner }) - setStructuredAgentSessionHost(rig.host) - await rig.store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - const resolveRecovery = vi.spyOn( - rig.host.collaboratorsForTests().runtimeState, - 'resolveRecovery' - ) - // Open, at rest, on a lease still recovering. - await rig.host.journalSnapshot(SESSION) - expect(rig.host.hasSession(SESSION)).toBe(true) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(probeOwner).not.toHaveBeenCalled() - expect(resolveRecovery).not.toHaveBeenCalled() - }) -}) - -describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { - const stopReason = 'Codex never finished starting, so Orca stopped it.' - const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) - // Held for the starting child, which never proves its start. - const reader = collectSubscriber() - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => - expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - ) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - await vi.waitFor(() => - expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) - ) - ) - const errorRows = reader.events.flatMap((event) => - event.type === 'batch' - ? event.batch.items.filter( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - ) - : [] - ) - expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ - stopReason - ]) - - const again = await rig.host.send(CALLER, restTestSend('try again', fence())) - expect(again.ok).toBe(true) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1), COLD_START) - await expect(sweepOnce(rig.host)).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index cd9ecc7e7418..d510c14e2b56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -168,6 +168,9 @@ function openHost(overrides: Partial): void { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -431,6 +434,9 @@ describe('a turn a read reached before the reconcile proved its owner dead', () const acquire = vi.fn() openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -473,6 +479,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -486,6 +493,9 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ) openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -552,6 +562,7 @@ async function hostWithFailingFirstStart(failure: Error) { throw failure } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -565,6 +576,9 @@ async function hostWithFailingFirstStart(failure: Error) { ) openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, releaseAcquisition: async () => true, dispatch: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 0ce12f92dad5..91b24f82f0da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -31,9 +31,9 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - startThatFailedWhileQueued, recordStructuredAgentSessionStartFailure, - refusedStructuredAgentSessionStart + refusedStructuredAgentSessionStart, + startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts deleted file mode 100644 index bbcadedabfef..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -// A record this build cannot drive (its agent's definition now names another transport or account -// variable, or the record pins another agent's variable) stays readable; only starting its agent is -// refused, at the one launch admission every agent passes through. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentRegistry } from './structured-agent-registry' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentSessionHost } from './structured-agent-session-host' - -const NOW = 1_800_000_000_000 -const SESSION = 'grok-session' -const GROK_HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/home/dev/.grok' } -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -const GROK: StructuredAgentDefinition = { - ...CODEX_STRUCTURED_AGENT, - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - ...CODEX_STRUCTURED_AGENT.capabilities, - rewind: false, - compact: false, - threadGoal: false - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. -const NO_METHODS = {} as StructuredAgentSessionAdapter -const grokRegistered = (definition: StructuredAgentDefinition = GROK) => - new StructuredAgentRegistry([{ definition, adapter: NO_METHODS }]) - -function params( - operation: string, - expectedRuntimeFence: number | null, - accountHome = GROK_HOME -): AgentSessionAttachParams { - const attach: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-${operation.padStart(32, '0')}`, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'grok', - agent: 'grok', - accountHome, - runtimeKind: 'native' - } - return { - ...attach, - envelope: { - ...attach.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(attach) - }) - } - } -} - -function grokAdapter(): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `grok-link-${fence}`, - handle: { transport: 'acp', agent: 'grok', nativeId: 'grok-native-1' }, - origin: fence === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -async function attach(input: { - agents: StructuredAgentRegistry - adapter: StructuredAgentSessionAdapter - params: AgentSessionAttachParams - spawnToken: string - store?: AgentSessionRecordStore -}) { - const store = input.store ?? (await openTestAgentSessionRecordStore(root!)) - const result = await performAttach({ - agents: input.agents, - logger: createStructuredAgentSessionLogger(), - store, - adapter: input.adapter, - openConversation: async (record) => { - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - return journal - }, - authority: { - spawnToken: input.spawnToken, - claimKeyId: 'key-1', - handoffOperationId: input.params.envelope.clientOperationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: input.params, - now: () => NOW, - onAttached: async (attached) => { - await attached.journal.close() - } - }) - return { result, store } -} - -/** Persist and reopen a Grok chat whose original child is gone. */ -async function createdGrokChat() { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const created = await attach({ - agents: grokRegistered(), - adapter: grokAdapter(), - params: params('1', null), - spawnToken: 'spawn-a' - }) - expect(created.result).toMatchObject({ ok: true }) - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) - return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } -} - -const UNSUPPORTED = { - ok: false, - refusal: { code: 'structured_agent_session_unsupported', details: { reason: 'hostUnsupported' } } -} - -it('refuses an unregistered agent before creating a durable record', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const { result, store } = await attach({ - agents: new StructuredAgentRegistry([]), - adapter, - params: params('1', null), - spawnToken: 'spawn-unregistered' - }) - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) - -it('keeps saved history readable without a registration and resumes when it returns', async () => { - const created = await createdGrokChat() - await created.store.setSessionTabVisibility(SESSION, true) - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))( - created.store.getRecord(SESSION)! - ) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'saved-reply' }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Saved reply' }] }, - { fence: created.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - - const store = await openTestAgentSessionRecordStore(root!) - const agents = new StructuredAgentRegistry([]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - const acquire = vi.spyOn(router, 'acquire') - const host = new StructuredAgentSessionHost({ - store, - agents, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root!), - claimKeyId: 'key-1', - probeOwner: async () => ({ outcome: 'pid-absent' }), - logger: createStructuredAgentSessionLogger(), - now: () => NOW + 2 - }) - try { - expect(router.supportsCreate(params('2', created.fence).location, 'grok')).toBe(false) - expect(store.listVisibleSessionIds()).toEqual([SESSION]) - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - await expect(host.revealSession(SESSION)).resolves.toMatchObject({ - sessionId: SESSION, - agent: 'grok', - readable: true - }) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history).toMatchObject({ - ok: true, - page: { - items: expect.arrayContaining([ - expect.objectContaining({ - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'Saved reply' }] - } - }) - ]) - } - }) - const before = store.getRecord(SESSION)!.lease.runtimeFence - await expect( - host.attach({ callerKey: 'client-1' }, params('2', before)) - ).resolves.toMatchObject(UNSUPPORTED) - expect(acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(before) - } finally { - await host.flushAllStreamedEvents() - } - - const adapter = grokAdapter() - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('3', store.getRecord(SESSION)!.lease.runtimeFence), - spawnToken: 'spawn-restored', - store - }) - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(store.listVisibleSessionIds()).toEqual([SESSION]) -}) - -it.each([ - ['its transport', { ...GROK, handleTransport: 'grok-native' }], - ['its account variable', { ...GROK, accountHomeVariable: 'GROK_CONFIG_DIR' }] -])( - 'keeps a chat readable after its agent changes %s, and refuses to start it', - async (_change, changed) => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(changed), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - // Still listed and readable: its tab publishes and its history opens. - expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) - expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') - } -) - -it("refuses to start a chat that pins another agent's account variable", async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('1', null, { variable: 'CODEX_HOME', path: '/home/dev/.codex' }), - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() -}) - -it('starts the same chat again once its agent is declared as it was', async () => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() -}) - -it('refuses an attach whose agent is not the session it names', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const mismatched = { ...params('1', null), agent: 'codex' } - - const { result, store } = await attach({ - agents: grokRegistered(), - adapter, - params: { - ...mismatched, - envelope: { - ...mismatched.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(mismatched) - }) - } - }, - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } - }) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 141afd9406eb..513e64804247 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -10,10 +10,7 @@ import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { - settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionProviderStarted -} from './structured-agent-session-provider-started' +import { settleStructuredAgentSessionOptionsReported } from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, @@ -132,9 +129,6 @@ export class StructuredAgentSessionEventRecovery { * nothing restarts the child. The next send does, through the delivery loop, which also owns any * message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { - if (event.type === 'started') { - return settleStructuredAgentSessionProviderStarted(this.context, event) - } if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index a469af94120e..00ea74c46541 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -43,6 +43,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-failed-create-verdict-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -58,6 +59,9 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts deleted file mode 100644 index e510916b0973..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -// A child that dies between spawn and journal attach can still write through the host's event -// sink, which attach unbound and never re-bound. That queue must die with the failed create, or -// the next attach's drain barrier and shutdown's flush wait on it forever. - -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 type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionPreSpawnError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} 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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-failed-create-sink-')) - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - // A resume continues the chain the first start created. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${fence}` - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -describe('a create that fails after its child wrote through the unbound sink', () => { - it.each([ - // The common failed start: answered as a refusal. - ['refused', new Error(EXIT_REASON)], - // A failure the attach cannot classify still throws, and must release the sink too. - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])( - 'releases the sink when %s, so a new create and shutdown both proceed', - async (_how, cause) => { - acquire.mockImplementationOnce(async ({ events }) => { - // The published child's exit reached the translator before any journal was attached. - events?.setActivity?.(null) - throw cause - }) - - const failed = host.attach(CALLER, hostTestAttachParams(null)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ - ok: false, - refusal: { message: "Codex couldn't restart. Send your message to try again." } - })) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(acquire).toHaveBeenCalledTimes(2) - } - ) - - it.each([ - ['refused', new Error(EXIT_REASON)], - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])('releases the sink when a resume of a still-indexed session is %s', async (_how, cause) => { - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: `generation-${exitedFence}` - }) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockImplementationOnce(async ({ events }) => { - events?.setActivity?.(null) - throw cause - }) - - // The session stays indexed across this failure: it is a resume, not a create. - const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ ok: false })) - - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(3) - // Only the adopted child's sink still takes writes: the failed attempt's closed with it, and - // the exited generation's closed when the resume replaced it. - const [exited, failedAttempt, resumed] = acquire.mock.calls.map(([input]) => input.events) - expect(failedAttempt?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(exited?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(resumed?.tryPublish?.()).toEqual({ accepted: true }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts deleted file mode 100644 index f308b7903983..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ /dev/null @@ -1,283 +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, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} 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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let answerPrompt: Mock -let ordinal = 0 - -function adapter(): StructuredAgentSessionAdapter { - const dispatch = vi.fn(async (): Promise => { - ordinal += 1 - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } - } - }) - return { - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt, - setOption: vi.fn(async () => undefined) - } -} - -async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('seedGroupedQuestion requires an acquired session') - } - events.appendItem( - identity, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(identity) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null - if (!appended) { - throw new Error('provider question was not written to the journal') - } - return { itemId, revision: appended.revision } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) - resetHostTestOperationIds() - ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('grouped question admission', () => { - it('reads the packed answer an older client sends into structured answers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [ - { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - const optionId = encodeAgentSessionQuestionAnswers(answers) - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - expect(result).toMatchObject({ - ok: true, - value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ itemId: prompt.itemId, response: { kind: 'answers', answers } }) - ) - }) - - it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(typed.length).toBeGreaterThan(1024) - expect(result).toMatchObject({ - ok: true, - value: { - resolution: { - state: 'resolved', - selectedOptionId: encodeAgentSessionQuestionAnswers(answers), - answers - } - } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ response: { kind: 'answers', answers } }) - ) - }) - - it('refuses answers that do not match the questions without reaching the provider', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(answerPrompt).not.toHaveBeenCalled() - }) -}) - -describe('a grouped question answered again', () => { - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - - async function answer( - prompt: { itemId: string; revision: number }, - given: typeof answers - ): ReturnType { - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers: given } - return host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - } - - it('answers a re-click with the same answers from the resolution it holds', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - const first = await answer(prompt, answers) - - // The reply to the first was lost, so the re-click still names the revision it saw. - const again = await answer(prompt, answers) - - expect(first.ok).toBe(true) - expect(again).toEqual({ ...first, replayed: false }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) - - it('refuses different answers to a resolved question as moved on', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - await answer(prompt, answers) - - const different = await answer(prompt, [ - { questionId: 'q1', optionIds: ['target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) - - expect(different).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - details: { reason: 'promptMoved' }, - resolution: { answers } - } - }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index ebcd15553a78..969a3346c1b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -1,64 +1,52 @@ -// A fake adapter's children prove their start once published, except those a test holds `starting`. +// Fake protocol opens use the same awaited host path as production adapters. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type Acquire = StructuredAgentSessionAdapter['acquire'] +type Open = StructuredAgentSessionAdapter['open'] export type StructuredAgentSessionHeldStarts = { - /** Wraps an acquire so the child it answers stays `starting`. */ held(acquire: Acquire): Acquire - /** Wraps the adapter's acquire: every child not held proves its start once published. */ - wrap(adapter: A): A - /** Answers the pending protocol open. */ + wrap(adapter: A): A & StructuredAgentSessionAdapter prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise clear(): void } export function structuredAgentSessionHeldStarts( - _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } + _host: () => unknown ): StructuredAgentSessionHeldStarts { - const heldGenerations = new Map>() - let generations = 0 + const held = new Map void>() + const waiting = new Map>() const prove: StructuredAgentSessionHeldStarts['prove'] = async (child) => { - heldGenerations.get(child.acquisitionGeneration)?.resolve() + held.get(child.acquisitionGeneration)?.() await Promise.resolve() } return { held: (acquire) => async (input) => { const acquired = await acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` - heldGenerations.set(acquisitionGeneration, Promise.withResolvers()) - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + const generation = acquired.acquisitionGeneration + waiting.set(generation, new Promise((resolve) => held.set(generation, resolve))) + return acquired }, wrap: (adapter) => { - const acquire: Acquire = async (input) => { - const acquired = await adapter.acquire(input) - const acquisitionGeneration = - acquired.acquisitionGeneration ?? `instant-start-${++generations}` - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } - } - const open: NonNullable = async (input) => { - if (input.acquisitionGeneration) { - await heldGenerations.get(input.acquisitionGeneration)?.promise - heldGenerations.delete(input.acquisitionGeneration) + const open: Open = async (input) => { + const generation = input.acquisitionGeneration + if (generation) { + await waiting.get(generation) + waiting.delete(generation) + held.delete(generation) } - return adapter.open - ? adapter.open(input) - : { - reportedOptions: { model: '' } - } + return adapter.open(input) } - return { ...adapter, acquire, open } + return { ...adapter, open } }, prove, clear: () => { - for (const held of heldGenerations.values()) { - held.resolve() + for (const resolve of held.values()) { + resolve() } - heldGenerations.clear() + held.clear() + waiting.clear() } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts deleted file mode 100644 index 8669a3c92d24..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -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 { AgentSessionRecord } from '../../../shared/agent-session-record' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { probeAgentSessionProcessIdentity } from '../../runtime/agent-session-process-identity-probe' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - closeTestJournalHostDatabase, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams -} from './structured-agent-session-host-test-data' -import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const RENEW_INTERVAL_MS = 10_000 -const roots: string[] = [] - -afterEach(async () => { - vi.useRealTimers() - for (const root of roots) { - closeTestJournalHostDatabase(root) - } - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -/** A real host whose adapter spawned a child it could not read a start time for. */ -async function hostWithStartTimeLessChild(platform: NodeJS.Platform) { - const root = await mkdtemp(join(tmpdir(), 'orca-host-held-renewal-')) - roots.push(root) - const store = await openTestAgentSessionRecordStore(root) - const clock = { now: NOW } - const process = { live: true } - const holdsLiveProviderProcess = vi.fn( - (_sessionId: string, generation: string) => process.live && generation === 'gen-1' - ) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: null, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'gen-1' - }), - holdsLiveProviderProcess, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - // The real PID probe with the pid present: no start time and no token echo to match. - const probeOwner = vi.fn((record: AgentSessionRecord) => - probeAgentSessionProcessIdentity({ - identity: record.lease.ownerProcess!, - deps: { platform, isPidPresent: () => true } - }) - ) - const host = new StructuredAgentSessionHost({ - logger: recordingProductionStructuredAgentSessionLogger().logger, - store, - adapter, - agents: claudeAndCodexDeclared(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => clock.now - }) - return { host, store, clock, process, probeOwner, holdsLiveProviderProcess } -} - -describe.each(['darwin', 'win32'] as const)('host lease renewal on %s', (platform) => { - it('renews a held child without a PID probe until the adapter sees its root exit', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - const { host, store, clock, process, probeOwner, holdsLiveProviderProcess } = - await hostWithStartTimeLessChild(platform) - const attached = await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - - clock.now = NOW + RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - ) - expect(probeOwner).not.toHaveBeenCalled() - expect(holdsLiveProviderProcess).toHaveBeenCalledWith(SESSION, 'gen-1') - - // The adapter saw the root exit; the host has not settled it, so its child is still on record. - process.live = false - clock.now = NOW + 2 * RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => expect(probeOwner).toHaveBeenCalledOnce()) - - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - await host.flushAllStreamedEvents() - }) -}) 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 c19d875715fe..c42dc0c90091 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 @@ -140,7 +140,7 @@ export function cancelStructuredAgentSessionTurn( const { prompt } = params const stoppedPrePrompt = !prompt && params.turnId === undefined - ? abortAcquireForStop(context, caller, params.envelope, plan) + ? abortProviderWaitForStop(context, caller, params.envelope, plan) : false // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). @@ -160,7 +160,7 @@ export function cancelStructuredAgentSessionTurn( * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. * Only a Stop admission would run now; one naming a turn is about a child already gone, so it * leaves a newer start alone. */ -function abortAcquireForStop( +function abortProviderWaitForStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts deleted file mode 100644 index f8f9745f8905..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// The helper is what the restart specs remove their temp directory behind, so what it waits for is -// load-bearing: a store commit that lands afterwards re-creates the directory it just removed. - -import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -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 { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - 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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -describe('abandoning a structured agent-session host', () => { - it('waits for the restart the delivery loop woke for an accepted send', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await openTestAgentSessionRecordStore(root) - // Holds the restart inside its provider acquisition, so the point teardown must not run past - // is exact rather than a timing window. - let gate: Promise | null = null - let openGate = (): void => {} - let reportEntered = (): void => {} - const entered = new Promise((resolve) => { - reportEntered = resolve - }) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => { - if (gate) { - reportEntered() - await gate - } - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - releaseAcquisition: async () => true, - setOption: async () => undefined - } - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - probeOwner: async () => ({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - // The conversation stays and its provider child does not, so the next send makes the delivery - // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION, 'evict') - gate = new Promise((resolve) => { - openGate = resolve - }) - - const body = hostTestMessage('delivery loop') - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-000000000000000000000000000003e9`, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - await entered - - let abandoned = false - const abandoning = abandonStructuredAgentSessionHost(host).then(() => { - abandoned = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(abandoned).toBe(false) - - openGate() - await abandoning - - // Nothing is left to put the directory back after this returns. - await rm(root, { recursive: true }) - await new Promise((resolve) => setTimeout(resolve, 200)) - expect(existsSync(root)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index d4fb189ede09..1f02b435bbf1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -87,6 +87,9 @@ function adapter(): StructuredAgentSessionAdapter { return startsWhenPublished( { acquire, + open: async () => ({ + reportedOptions: { model: '' } + }), releaseAcquisition, dispatch: async (input) => { await input.beforeDispatch?.() @@ -139,6 +142,7 @@ beforeEach(async () => { resetHostTestOperationIds() ordinal = 0 acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 684d9b776f67..6876ade6ddd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -69,7 +69,7 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** A starting acquisition stays `starting` until the host settles its `started` event; only then is + /** Every acquired child is `starting` until the host settles its open result; only then is * it handed input and are its reported options fact. */ phase: StructuredAgentSessionProviderChildPhase /** The queued message whose delivery started this child, fixed when the start is made; absent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index a1a99ce71836..b7c6834e1274 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -1,19 +1,17 @@ -// Fake children answer the host-awaited protocol open immediately. +// Fake providers publish their generation and return protocol facts from host-awaited open. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' export function startsWhenPublished( adapter: A, - _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } -): A { - let generations = 0 + _host: () => unknown +): A & StructuredAgentSessionAdapter { const acquire: StructuredAgentSessionAdapter['acquire'] = async (input) => { const acquired = await adapter.acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `instant-start-${++generations}` - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + if (!acquired.acquisitionGeneration) { + throw new Error('test provider did not publish its generation') + } + return acquired } return { ...adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 209260f1406c..e9584c8fe2e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -104,8 +104,11 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index cd26b96e5cd1..11bfd5dfdedc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -23,9 +23,9 @@ let host: StructuredAgentSessionHost function modelAdapterLiveness(registerOnAcquire: boolean): void { const { acquire, dispatch } = hostTestState() const live = new Set() - const spawn = acquire.getMockImplementation()! + const acquireChild = acquire.getMockImplementation()! acquire.mockImplementation(async (input) => { - const acquisition = await spawn(input) + const acquisition = await acquireChild(input) if (registerOnAcquire) { live.add(input.identity.sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index e7a814ec0481..a5b908c4a670 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -51,6 +51,9 @@ function openHost(): void { store, adapter: startsWhenPublished( { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), @@ -129,7 +132,7 @@ beforeEach(async () => { stopOwnerProcess = vi.fn() dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts deleted file mode 100644 index 9b65150cc704..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// The chat's own Stop and every session list read whether the main agent is working through one -// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the -// status feed. Both leave the host from one publication edge, so once each has landed they must say -// the same thing. Against the real host, store, journal, stream and status feed. - -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 { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../../shared/structured-agent-session-reducer' -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 { - 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 { 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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let dispatch: Mock -let events: StructuredAgentSessionEventSink | undefined -/** The session's child records, as the host's store serves them to every surface. */ -let records: AgentChildWorkView[] -let chat: StructuredAgentSessionState -let listed: AgentSessionStatusSummary | undefined - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) - resetHostTestOperationIds() - events = undefined - records = [] - chat = EMPTY_STRUCTURED_AGENT_SESSION - listed = undefined - // Written, and the provider has neither opened a turn for it nor answered it. - dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished( - { - acquire: async (input) => { - events = input.events - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${input.fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - host.subscribe({ - id: 'chat-1', - sessionId: SESSION, - emit: (event) => { - chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) - } - }) - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - listed = structuredClone(event.session) - } else if (event.type === 'snapshot') { - listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed - } - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -async function send(text: string): Promise { - const body = hostTestMessage(text) - const clientOperationId = hostTestOperationId() - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - return clientOperationId -} - -async function provider(ordinal: number, body: AgentJournalItemBody): Promise { - events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - events!.publish() - await host.flushStreamedEvents(SESSION) -} - -/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ -function chatReadsWorking(): boolean { - return isStructuredAgentSessionMainAgentWorking( - activeStructuredAgentSessionTurnId(chat.items), - chat.submissions, - chat.fence - ) -} - -/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ -function listReads(): { mainAgent: string; row: string } { - const summary = listed - if (!summary?.status) { - return { mainAgent: 'none', row: 'none' } - } - const status = structuredAgentSessionAgentStatus({ - ...summary, - status: summary.status, - childWork: summary.children - }) - return { mainAgent: status.mainAgent.state, row: status.state } -} - -describe('the main agent read by the chat and by a session list', () => { - it('agrees while a rate-limited request retries with no turn open', async () => { - const id = await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. - await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) - await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) - - expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) - expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - }) - - it('agrees once the handed-over message is answered and only a subagent still runs', async () => { - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } - }) - await send('fan out') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) - records = [ - { - id: 'child-1', - providerId: 'task-1', - kind: 'agent', - name: 'deep_review', - state: 'working', - membership: 'live', - firstObservedAt: NOW, - observedAt: NOW, - stoppable: false, - invocation: { invocationId: 'task-1', generation: 1 } - } - ] - host.publishChildWorkEvidence(SESSION, []) - await host.flushStreamedEvents(SESSION) - - expect(chat.backgroundTasks?.children).toHaveLength(1) - // The row reads Working for the subagent; the main agent, and so Stop, does not. - expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) - expect(chatReadsWorking()).toBe(false) - }) - - it('agrees when the child that was handed the message exits', async () => { - await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - expect(chatReadsWorking()).toBe(true) - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reason: 'codex app-server crashed', - cause: 'unexpected-exit' - }) - await host.flushStreamedEvents(SESSION) - - expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) - expect(chatReadsWorking()).toBe(false) - expect(listReads().mainAgent).not.toBe('working') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts deleted file mode 100644 index 7eccfe5f22ba..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' - -export async function readNativeSessionOptions(input: { - adapter: Pick - sessionId: string - fence: number - priorOptions?: Readonly> -}): Promise> | undefined> { - const { adapter, sessionId, fence, priorOptions } = input - const reported = await adapter.readOptions?.({ sessionId, fence }) - if (!reported) { - return undefined - } - return nativeSessionOptionsFromReport({ - reported: reported.current, - ...(priorOptions ? { priorOptions } : {}) - }) -} - -/** Legacy acquisition facts replace execution choices; unrelated saved launch fields survive. */ -export function nativeSessionOptionsFromReport(input: { - reported: AgentSessionOptionsResult['current'] - priorOptions?: Readonly> - /** Values the child showed it cannot run: its report of the same value is not kept. */ - retired?: Readonly> -}): Readonly> { - const { reported, priorOptions } = input - const restored = priorOptions ? { ...priorOptions } : {} - delete restored.model - delete restored.effort - delete restored.fastMode - delete restored.serviceTier - // A reported tier is the speed itself; the Fast it implies for older clients is not kept. - const fastMode = - reported.fastMode === undefined || reported.serviceTier !== undefined - ? undefined - : encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode) - const options: Record = { - ...restored, - ...(reported.model ? { model: reported.model } : {}), - ...(reported.effort ? { effort: reported.effort } : {}), - ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}), - ...(reported.serviceTier ? { serviceTier: reported.serviceTier } : {}) - } - for (const [key, value] of Object.entries(input.retired ?? {})) { - if (options[key] === value) { - delete options[key] - } - } - return options -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts deleted file mode 100644 index df310b364a86..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ /dev/null @@ -1,241 +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, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} 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 { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} 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 { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const DEFAULT_MODEL = 'gpt-default' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let router: StructuredAgentSessionAdapter -let acquire: Mock -let closeNativeSession: Mock> -let activeModel: string -let activeEffort: string | null -let optionFailure: Error | null -/** What the adapter's closeSession reports about the child's exit. */ -let closeSessionExit = true -const dispatchedModels: string[] = [] - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options, identity }) => { - activeModel = options?.model ?? DEFAULT_MODEL - activeEffort = options?.effort ?? null - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` - // The handshake answers at once, reporting the options the child runs with. - void host.handleAdapterEvent({ - type: 'started', - sessionId: identity.sessionId, - fence, - acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } - }) - return { - providerChildPhase: 'starting' as const, - acquisitionGeneration, - process: { - hostId: 'local', - pid: 4200 + acquire.mock.calls.length, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `native-link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeNativeSession = vi.fn(async () => { - activeModel = DEFAULT_MODEL - return closeSessionExit - }) - return { - supportsLocation: () => true, - acquire, - dispatch: vi.fn(async ({ executionOptions }) => { - dispatchedModels.push(executionOptions?.model ?? activeModel) - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } - }), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async ({ key, value }) => { - if (optionFailure) { - const error = optionFailure - optionFailure = null - throw error - } - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } - return { - model: activeModel, - ...(activeEffort ? { effort: activeEffort } : {}) - } - }), - readOptions: vi.fn(async () => ({ - current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - models: [] - })), - closeSession: closeNativeSession - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-option-settlement-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - optionFailure = null - closeSessionExit = true - dispatchedModels.length = 0 - const accountHome = join(root, 'codex-home') - store = await openTestAgentSessionRecordStore(root) - router = adapter() - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-native', - now: () => NOW - }) - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - expect(attached).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured session options and close', () => { - it('keeps provider readback out of the saved next-message choice', async () => { - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'first task' }] - } - await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - // Accepted, then handed over by the delivery loop; the status is read once it answered. - await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - const events: AgentSessionStatusEvent[] = [] - host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', optionSelection: {} })] - } - ]) - expect(events[0]).not.toHaveProperty('sessions.0.model') - expect(store.getRecord(SESSION)?.options).toBeUndefined() - }) - - // Closing a chat used to leave its provider child resident for the whole app session: the host's - // session map had no delete and the only teardown was app quit. - it('stops the provider child and forgets the session when the chat closes', async () => { - expect(host.hasSession(SESSION)).toBe(true) - - await host.close(SESSION, 'evict') - - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - expect(host.hasSession(SESSION)).toBe(false) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).toHaveBeenCalledOnce() - }) - - it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION, 'evict') - closeNativeSession.mockClear() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).not.toHaveBeenCalled() - }) -}) - -it('applies running Codex model and effort picks to the next message', async () => { - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - for (const [key, value] of [ - ['model', 'gpt-picked'], - ['effort', 'high'] - ]) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - ).toMatchObject({ ok: true }) - } - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'with the new choices' }] - } - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) - expect(vi.mocked(router.dispatch).mock.calls.at(-1)?.[0].executionOptions?.effort).toBe('high') - expect(router.setOption).not.toHaveBeenCalled() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts deleted file mode 100644 index 2d82658a1bd1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -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 { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../../shared/claude-profile-routing' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { mapRuntimeError } from '../../runtime/rpc/errors' -import { - AgentSessionPreSpawnError, - type AgentSessionPreSpawnReason, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - vi.restoreAllMocks() - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function createParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - runtimeKind: 'native' - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ -async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { - root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await openTestAgentSessionRecordStore(root) - const unused = async (): Promise => { - throw new Error('not reached before a spawn') - } - const adapter: StructuredAgentSessionAdapter = { - acquire: async () => { - throw thrown - }, - dispatch: unused, - cancelTurn: unused, - answerPrompt: unused, - setOption: unused - } - const input = { - agents: NO_STRUCTURED_AGENTS, - store, - adapter, - logger: createStructuredAgentSessionLogger(), - journalDatabase: openTestJournalHostDatabase(root), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' as const } - }, - callerKey: 'client-1', - params: createParams(), - now: () => NOW, - onAttached: () => {} - } - const first = await performAttach(input).then( - (result) => result, - (error: unknown) => error - ) - if (first instanceof Error) { - expect(first).toBeInstanceOf(AgentSessionPreSpawnError) - } - return { - first: - first instanceof Error ? mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first) : first, - replay: await performAttach(input) - } -} - -describe('a create that fails before any process spawns', () => { - it('answers a safe saved Arguments problem on the first call and replay', async () => { - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError( - new StructuredAgentArgumentsError('Claude', '--model=private', 'multipleValues') - ) - ) - const sentence = - "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." - for (const result of [first, replay]) { - expect(result).toMatchObject({ - ok: false, - refusal: { - message: sentence, - details: { - reason: 'attachFailed', - argumentProblem: { agent: 'Claude', option: '--model', problem: 'multipleValues' } - } - } - }) - } - expect(JSON.stringify([first, replay])).not.toContain('private') - }) - - it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ - [ - 'the managed account env override', - 'This Claude launch defines explicit Anthropic auth environment variables.', - 'managedAccountEnvOverride', - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - ], - [ - 'an account switch in progress', - 'A Claude account switch is in progress. Try again after it finishes.', - 'accountSwitchInProgress', - 'A Claude account switch is in progress. Try again after it finishes.' - ], - [ - 'a Claude account added in WSL', - 'structured Claude is not offered under the active managed Claude account', - 'managedAccountUnsupported', - 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' - ], - [ - 'a floating chat whose launch folder is gone', - 'the folder this chat ran in no longer exists: /gone/floating', - 'launchFolderMissing', - 'The folder this chat ran in no longer exists. Restore it to continue this chat.' - ], - [ - 'a resume whose transcript is in another Claude account', - 'claude transcript is not in the selected account', - 'historyInOtherAccount', - "This chat's history is in another Claude account. Switch back to that account to continue it." - ], - [ - 'a selected Claude account whose folder is gone', - CLAUDE_PROFILE_MISSING_MESSAGE, - 'claudeAccountFolderMissing', - CLAUDE_PROFILE_MISSING_MESSAGE - ], - [ - 'a selected Claude account that could not be set up', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - 'claudeAccountSetupFailed', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ], - [ - 'a Command setting that names no runnable program', - 'the claude Command setting is not a runnable program', - 'agentCommandNotRunnable', - "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." - ], - [ - "Orca's own reason", - 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', - undefined, - "Claude couldn't start. Send your message to try again." - ] - ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) - ) - - // Thrown as before: the wire code is unchanged, only the words are the replay's. - expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) - expect(replay).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: sentence, - ...(reason ? { details: { reason } } : {}) - } - }) - if (!reason) { - expect(replay).not.toHaveProperty('refusal.details') - } - if (raw !== sentence) { - expect(JSON.stringify([first, replay])).not.toContain(raw) - } - // What failed is kept for the log. - expect(warn).toHaveBeenCalledWith( - '[agent-session] provider-start: starting the provider failed', - expect.objectContaining({ - scope: 'provider-start', - error: expect.objectContaining({ message: raw }) - }) - ) - }) - - it('keeps a message that is itself a code, and the wire code it maps to', async () => { - const { first } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error('runtime_unavailable')) - ) - - expect(first).toMatchObject({ - ok: false, - error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } - }) - }) - - it('keeps the situation of a pre-spawn error it wraps', () => { - const typed = new AgentSessionPreSpawnError(new Error('switching'), { - reason: 'accountSwitchInProgress' - }) - - expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 543abc1e2537..3e76030579a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -68,6 +68,21 @@ function attachParams( } } +function adapterForAcquire( + acquire: StructuredAgentSessionAdapter['acquire'] +): StructuredAgentSessionAdapter { + return { + open: async () => ({ + reportedOptions: { model: '' } + }), + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) @@ -75,13 +90,9 @@ describe('processless structured session reservation', () => { const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { - supportsCreate: vi.fn(() => false), - acquire, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + ...adapterForAcquire(acquire), + supportsCreate: vi.fn(() => false) + } await expect( performAttach({ @@ -117,10 +128,11 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only `supportsCreate` and `acquire` on this fake adapter. const adapter = { + ...adapterForAcquire(vi.fn()), supportsCreate, acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'link-1', @@ -130,7 +142,7 @@ describe('processless structured session reservation', () => { observedAt: NOW } })) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -169,7 +181,7 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) const acquire = vi.fn() - const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + const adapter = { ...adapterForAcquire(acquire), supportsCreate } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -216,11 +228,11 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const store = await openTestAgentSessionRecordStore(root) - const adapter = { - acquire: vi.fn(async () => { + const adapter = adapterForAcquire( + vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) }) - } as unknown as StructuredAgentSessionAdapter + ) const settlement = vi.spyOn(store, 'settleFailedAcquisition') await expect( @@ -270,12 +282,13 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const store = await openTestAgentSessionRecordStore(root) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only the members this fake adapter defines. const adapter = { + ...adapterForAcquire(vi.fn()), acquire: vi .fn() .mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error('launch not ready'))) .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -291,7 +304,7 @@ describe('processless structured session reservation', () => { } })), releaseAcquisition: vi.fn(async () => true) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts deleted file mode 100644 index d4dbb7c538ea..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ /dev/null @@ -1,920 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The provider child is its own record on the conversation: stopping it, losing it or failing to -// start it ends the child, never the conversation. Against the real host, store and journal, with a -// live subscriber opened before each action. - -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 type { - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' -import { - agentSessionFailureFact, - type AgentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -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 { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { - HOST_TEST_LOCATION, - 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 { 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 { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { - surface: 'rejection' -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let dispatch: Mock -let adapterExtras: Partial - -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -function generation(): string { - return `generation-${acquire.mock.calls.length}` -} - -const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: generation(), - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } -}) - -const starts = structuredAgentSessionHeldStarts(() => host) - -/** A child whose handshake is still running: `starting` until the test's `prove()`. */ -const spawnStartingChild = starts.held(spawnChild) - -function startHost(): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: starts.wrap({ - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - ...adapterExtras, - acquire - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) -} - -async function restartHost(): Promise { - await host.flushAllStreamedEvents() - startHost() -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) - resetHostTestOperationIds() - adapterExtras = {} - starts.clear() - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async (input) => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION, 'evict') -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string) { - const body = hostTestMessage(text) - return { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - } -} - -async function accept(text: string, options: { person?: true } = {}): Promise { - const params = sendParams(text) - const sent = await host.send(CALLER, { ...params, ...(options.person ? { userSend: true } : {}) }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - return params.envelope.clientOperationId -} - -function stop(clientOperationId = hostTestOperationId()) { - const turnId = 'turn-none' - return host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: { turnId } - }) - }, - turnId - }) -} - -async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) -} - -async function statusRows(): Promise< - { - itemId: string - text: string - tone?: string - failure?: AgentSessionFailureFact - }[] -> { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' - ? [ - { - itemId: item.itemId, - text: item.body.text, - ...(item.body.tone ? { tone: item.body.tone } : {}), - ...(item.body.failure ? { failure: item.body.failure } : {}) - } - ] - : [] - ) -} - -/** The child the conversation has now, as its lifecycle events name it. */ -function currentChild() { - const child = conversation()?.child - if (!child?.generation) { - throw new Error('no child indexed') - } - return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } -} - -/** The current child proves its start, as a publish-first provider's `started` event does. */ -function prove(): Promise { - const { acquisitionGeneration, ...child } = currentChild() - return host.handleAdapterEvent({ - type: 'started', - ...child, - acquisitionGeneration, - reportedOptions: { model: 'default' } - }) -} - -function exit(child: ReturnType, reason: string) { - return host.handleAdapterEvent({ - type: 'ended', - ...child, - reason, - // As an adapter reports it: the exit's stderr as a log detail. - failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, - cause: 'unexpected-exit' - }) -} - -function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { - return events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === id && entry.dispatchState === 'rejected' - ) - ) -} - -function conversation() { - return host['sessions'].get(SESSION) -} - -async function subscribe(): Promise { - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: (event) => events.push(structuredClone(event)) - }) - return events -} - -/** The chat's status row as a session list sees it, frame by frame. */ -function watchStatus(): AgentSessionStatusSummary[] { - const frames: AgentSessionStatusSummary[] = [] - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - frames.push(event.session) - } - } - }) - return frames -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - -describe('Stop on a child still proving its start', () => { - it('ends the child and keeps the conversation and its readers (R1)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('hello') - const journal = conversation()?.journal - const events = await subscribe() - const frames = watchStatus() - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - expect((await submission(first))?.handedOverAt).toBeUndefined() - - const stopId = hostTestOperationId() - expect(await stop(stopId)).toMatchObject({ ok: true, value: { cancelled: true } }) - - // The same conversation: no reopen, and the chat told it is idle again. - expect(conversation()?.journal).toBe(journal) - expect(conversation()?.child).toBeNull() - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) - expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') - expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - expect(await statusRows()).toEqual([ - { - itemId: `orca:stop%3A${stopId}`, - text: 'Cancellation requested.' - } - ]) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - - const next = await accept('after stop') - await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) - // The reader opened before the Stop saw the next message delivered on the same stream. - expect( - events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === next && entry.dispatchState === 'accepted' - ) - ) - ).toBe(true) - }) -}) - -describe('settling an earlier child before the next one takes its message', () => { - it("settles the earlier child's turn from its death evidence and leaves the queued message to the new child (R1)", async () => { - // The earlier child exited mid-turn; the released lease keeps only its death evidence. - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { - ...record.lease, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW - 1_000 } - } - })) - const releasedFence = store.getRecord(SESSION)!.lease.runtimeFence - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: HOST_TEST_LOCATION.workspaceId, - hostId: HOST_TEST_LOCATION.executionHostId, - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - }, - database: openTestJournalHostDatabase(root) - }) - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const id = await accept('for the next child') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. - const items = conversation()!.journal.snapshot().items - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ - turnId: 'earlier-turn', - state: 'interrupted', - completedAt: NOW - 1_000 - }) - ) - expect( - items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ) - // Handed over at the new child's fence, which the attach reserved after settling. - const newFence = store.getRecord(SESSION)!.lease.runtimeFence - expect(newFence).toBeGreaterThan(releasedFence) - expect((await submission(id))?.fence).toBe(newFence) - expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) - }) -}) - -const START_EXIT = 'claude stream-json exited (code 1)' -const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' -const START_FAILURE: SubmissionRejectionFact = { - kind: 'providerStartFailed', - detail: { text: START_EXIT, audience: 'log' } -} - -describe('a published child that dies while it proves its start', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it('leaves one error row keyed by the start, and every message held for it rejected with it (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Both are held for the starting child, which never proves its start. - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const child = currentChild() - - await exit(child, EXIT) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) - } - expect(rejectedIn(events, second)).toBe(true) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -/** A start by an operation that needs the agent, such as a goal change, outside the loop. */ -function startForOperation() { - return host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) -} - -describe('a start another operation made that dies while a sent message waits on it', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // An operation that needs the agent starts a child that has not proven its start. - await startForOperation() - const operationChild = currentChild() - const events = await subscribe() - const params = sendParams('hello') - - // Accepted first; that child's exit is settled before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(operationChild, EXIT) - expect(await sent).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - expect(rejectedIn(events, id)).toBe(true) - // The setup's child and the operation's: nothing started again into the same failure. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('leaves a message sent after that start failed to a fresh start (R2)', async () => { - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await startForOperation() - await exit(currentChild(), EXIT) - expect(await statusRows()).toHaveLength(1) - - const id = await accept('after the failure') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { - await restartHost() - await startForOperation() - const params = sendParams('hello') - - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), 'codex app-server crashed') - await sent - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('hands over to a child started since the failed one, not to the failure (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - await startForOperation() - const params = sendParams('hello') - - // A second operation's start lands after the first child's exit, before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), EXIT) - const held = startForOperation() - await sent - await exited - await held - const id = params.envelope.clientOperationId - await prove() - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) -}) - -describe('a child that ends before its message is handed over', () => { - it('starts one child for the message, then rejects it and stops (R2)', async () => { - await restartHost() - // The child the loop starts proves its start, then exits as its start step returns: that exit, - // asked for on the lane then, lands before the handover step. - acquire.mockImplementation(async (input) => { - const child = await spawnStartingChild(input) - const { acquisitionGeneration } = child - if (acquisitionGeneration === undefined) { - throw new Error('spawnChild always names a generation') - } - void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) - void host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: input.fence, - acquisitionGeneration, - reason: 'codex app-server crashed', - failure: { - kind: 'providerExited', - detail: { text: 'codex app-server crashed', audience: 'log' } - }, - cause: 'unexpected-exit' - }) - return child - }) - const id = await accept('hello') - const events = await subscribe() - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The exit's stderr rides beside the sentence, never in it. - const failure = { - kind: 'providerStartFailed', - detail: { text: 'codex app-server crashed', audience: 'log' } - } - expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { ...failure, kind: 'providerStartFailed' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } - ]) - expect(rejectedIn(events, id)).toBe(true) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -describe('another child indexed after a Stop ended the starting one', () => { - it('hands the next message to the child now there once it proves its start (R2)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const stopped = currentChild() - expect(await stop()).toMatchObject({ ok: true }) - // An operation that needs the agent starts its own child before the second message is sent. - await host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) - const replacement = currentChild() - expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) - const secondId = await accept('second') - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - await host.flushStreamedEvents(SESSION) - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - - await prove() - - await eventually(async () => - expect((await submission(secondId))?.dispatchState).toBe('accepted') - ) - expect(conversation()?.child).toMatchObject({ - generation: replacement.acquisitionGeneration, - phase: 'ready' - }) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([secondId]) - expect(acquire).toHaveBeenCalledTimes(3) - expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - }) -}) - -// A quit settles nothing still queued: the next launch's open keeps a person's message as a held -// card and rejects the rest, as a crash's would. -describe('a quit with a message still queued', () => { - const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' - }) - - /** Read by the next launch, through the same open any reader takes. */ - async function afterRelaunch(id: string): Promise { - startHost() - await host.revealSession(SESSION) - return await submission(id) - } - - async function keptCards(): Promise { - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - return page.ok ? (page.page.queuedMessages ?? []).map((card) => card.messageId) : [] - } - - it('keeps a person’s message no child ever had as a held card for the next launch (R2)', async () => { - // Quit has begun — its first step stops the delivery loops — when this message is accepted. - host['conversationDelivery'].loop.dispose() - const id = await accept('hello', { person: true }) - expect(conversation()?.child).toBeNull() - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - expect(dispatch).not.toHaveBeenCalled() - }) - - it("rejects a message from Orca itself at the next launch, as a crash's would", async () => { - host['conversationDelivery'].loop.dispose() - const id = await accept('from orca') - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([]) - }) - - it('stops a start already in flight before it launches a child (R2)', async () => { - const starting = deferred() - const closeSession = vi.fn(async () => true) - adapterExtras = { closeSession } - await restartHost() - // The loop's start step is under way, but has not reached its attach yet. - const resolveRecovery = host['runtimeState'].resolveRecovery.bind(host['runtimeState']) - const recovering = vi.spyOn(host['runtimeState'], 'resolveRecovery') - recovering.mockImplementationOnce(async (sessionId) => { - await starting.promise - return resolveRecovery(sessionId) - }) - const id = await accept('hello', { person: true }) - await eventually(() => expect(recovering).toHaveBeenCalled()) - const acquiresBefore = acquire.mock.calls.length - - const quit = host.flushAllStreamedEvents() - starting.resolve() - await quit - - // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. - expect(acquire).toHaveBeenCalledTimes(acquiresBefore) - expect(closeSession).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { detail: 'reservation failed before spawn' } - }) - expect(dispatch).not.toHaveBeenCalled() - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - }) -}) - -describe('a send whose start failed, sent again with the same operation id', () => { - it('replays the recorded rejection and starts no second agent (R2)', async () => { - acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) - const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence - const params = sendParams('hello') - // A failed start is a rejected message, never a refused send. - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - const id = params.envelope.clientOperationId - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The start moved the fence while the message was out. - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) - const starts = acquire.mock.calls.length - - const replay = await host.send(CALLER, params) - - expect(replay).toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } - }) - await settleLoop() - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -async function settleLoop(): Promise { - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) -} - -describe('how a stopped child ends the start its loop was waiting on', () => { - /** A starting child the first message is held for, then the stop below; the message sent after - * the stop is the next delivery's. */ - async function stoppedWhileStarting(stop: () => Promise) { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await stop() - return accept('second') - } - - it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { - let stopId = '' - const second = await stoppedWhileStarting(async () => { - stopId = hostTestOperationId() - expect(await stop(stopId)).toMatchObject({ ok: true }) - }) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) - expect(await statusRows()).toEqual([ - { - itemId: `orca:stop%3A${stopId}`, - text: 'Cancellation requested.' - } - ]) - }) - - /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ - function closeStopOnly() { - return host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - ) - } - - /** A message queued for a starting child when the close's stop cut it: asked for during the - * start step, the stop runs before the handover. `beforeStart` runs after the stop and before - * the loop looks again. */ - async function closedWhileStarting(beforeStart: () => void = () => undefined) { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = host['serialize'](SESSION, async () => { - await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - starts = acquire.mock.calls.length - beforeStart() - }) - start.resolve() - await closed - await settleLoop() - return { first, starts } - } - - it('closes what was queued when the user closed the chat, and starts no child for it', async () => { - const { first, starts } = await closedWhileStarting() - - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - // The start step holds the queue: the message is still queued when the close's stop runs. - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first', { person: true }) - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = closeStopOnly().then(() => { - starts = acquire.mock.calls.length - }) - start.resolve() - await closed - await settleLoop() - - expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - // The close stopped the chat running: the card waits for its next turn, by a mark the - // re-check wrote once, when it kept the card. - const journal = conversation()!.journal - expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) - const marks = vi.spyOn(journal, 'appendQueueReopen') - await accept('second', { person: true }) - await settleLoop() - // A later send's re-check settles nothing, so it marks nothing past that send. - expect(marks).not.toHaveBeenCalled() - }) - - it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { - const { first, starts } = await closedWhileStarting(() => { - const journal = conversation()!.journal - const resolve = journal.resolveDispatch.bind(journal) - vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { - if (args[0].state === 'rejected' && args[0].rejection?.kind === 'chatClosed') { - vi.mocked(journal.resolveDispatch).mockImplementation(resolve) - throw new Error('disk full') - } - return resolve(...args) - }) - }) - - expect((await submission(first))?.dispatchState).toBe('pending') - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(dispatch).toHaveBeenCalledTimes(1) - }) - - it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { - await closedWhileStarting() - const session = conversation()! - await session.journal.rollEpoch( - 'corruption', - structuredAgentSessionConversationFence(store, SESSION) - ) - - const second = await accept('second') - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( - session.lastEndedChild!.endedAt.sequence - ) - }) - - it('goes on with a message sent after the close, never failing it', async () => { - const second = await stoppedWhileStarting(closeStopOnly) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect(await statusRows()).toEqual([]) - }) - - it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { - const reason = 'the start watchdog fired' - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) - - // What was held for the stopped start fails with it, said once; a later send starts afresh. - await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) - // The sentence is the constructor's, not the reason the stop was given. - const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(first)).toMatchObject({ - reason: text, - rejection: { kind: 'hostStopped' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } - ]) - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index 981636f2caad..30d3dd497fec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -5,7 +5,7 @@ import type { AgentSessionProviderHandleLink } from '../../../shared/agent-sessi export type StructuredAgentSessionProviderOpenInput = { sessionId: string fence: number - acquisitionGeneration: string | null + acquisitionGeneration: string signal: AbortSignal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index fbc8ba797193..501260915917 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -24,7 +24,6 @@ function awaitOpen() { } state.host.deps.adapter.acquire = async (input) => ({ ...(await acquire(input)), - providerChildPhase: 'starting', acquisitionGeneration: 'awaited-open' }) const pending = Promise.withResolvers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index 9e9020fdb1fc..537957a743f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -17,18 +17,21 @@ export async function openStructuredAgentSessionProviderChild( sessionId: string, child: StructuredAgentSessionProviderChildIdentity ): Promise { - const open = context.deps.adapter.open - if (!open || !context.runtimeState.startupAttempts.onClock(sessionId, child)) { + if (!context.runtimeState.startupAttempts.onClock(sessionId, child)) { return } const attempt = context.runtimeState.acquireAborts.begin(sessionId) try { attempt.signal.throwIfAborted() + const acquisitionGeneration = child.generation + if (acquisitionGeneration === null) { + throw new Error('An unopened provider child has no acquisition generation') + } const result = await waitForPromiseWithSignal( - open.call(context.deps.adapter, { + context.deps.adapter.open({ sessionId, fence: child.fence, - acquisitionGeneration: child.generation, + acquisitionGeneration, signal: attempt.signal }), attempt.signal @@ -38,13 +41,7 @@ export async function openStructuredAgentSessionProviderChild( } await settleStructuredAgentSessionProviderStarted( context, - { - ...result, - type: 'started', - sessionId, - fence: child.fence, - acquisitionGeneration: child.generation ?? '' - }, + { ...result, sessionId, fence: child.fence, acquisitionGeneration }, attempt.signal ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts deleted file mode 100644 index 09421a97cc56..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -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 { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CLAUDE_SESSION = 'claude-session' -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function claudeAdapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'claude', - acquire: async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle(CLAUDE_SESSION, null), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - }), - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost( - store: AgentSessionRecordStore, - probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] -): StructuredAgentSessionHost { - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: claudeAdapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => HOST_TEST_NOW - }) - hosts.push(host) - return host -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map(abandonStructuredAgentSessionHost)) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session provider restore', () => { - it('restores a durable Claude session tab with its recorded provider', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - - const reopenedStore = await openTestAgentSessionRecordStore(root) - const restarted = createHost(reopenedStore, async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - })) - - await restarted.restoreReadableSessions() - - expect(restarted.listSessionTabs()).toEqual([ - { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts deleted file mode 100644 index a256f02037e4..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// A chat that proves its start shows its account can start one, so the model catalog's held -// "can't start" reason is made due for the probe instead of trusted until its TTL runs out. - -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 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 { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} 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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -const providerStarted = vi.fn() - -function startHost(): void { - const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'generation-1' - }) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - releaseAcquisition: vi.fn(async () => true), - closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - modelCatalog: { - read: vi.fn(), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => {}), - stop: vi.fn(), - providerStarted - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-started-catalog-')) - resetHostTestOperationIds() - providerStarted.mockReset() - store = await openTestAgentSessionRecordStore(root) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The adapter's report that the child's protocol session exists. */ -function proveStart(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' } - }) -} - -it('a published child tells the catalog only once its start is proven, with its record', async () => { - startHost() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(providerStarted).not.toHaveBeenCalled() - await proveStart() - expect(providerStarted).toHaveBeenCalledTimes(1) - expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) -}) - -it('a re-attach to the live child proves nothing new and tells the catalog nothing', async () => { - startHost() - const params = hostTestAttachParams(null) - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - await proveStart() - const fence = store.getRecord(SESSION)?.lease.runtimeFence - // A reconnecting client replays its attach; the same operation re-attaches the live child. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(providerStarted).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts deleted file mode 100644 index e36758ff177c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// A publish-first create proves nothing about the model until Claude answers startup. The record -// must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// launch it as `--model` and silently move a user whose CLI default is not Sonnet. - -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 { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' -import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import { - fakeClaude, - PROVIDER_SESSION_ID -} from '../../claude/claude-structured-session-test-support' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - resetHostTestOperationIds -} 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 { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { createTestParams } from './structured-agent-session-create-test-fixture' - -const CALLER = { callerKey: 'client-1' } -const CLAUDE_MODELS = [ - { value: 'claude-sonnet', displayName: 'Sonnet' }, - { value: 'claude-opus-9', displayName: 'Opus 9' } -] -const LISTED = [ - expect.objectContaining({ id: 'claude-sonnet' }), - expect.objectContaining({ id: 'claude-opus-9' }) -] -const INIT_DELAY_MS = 40 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let adapter: ClaudeStructuredSessionAdapter -let lifecycle: Promise[] -let statuses: AgentSessionStatusEvent[] -let savedListings: { sessionId: string; listing: AgentModelCatalogLiveListing }[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-started-')) - resetHostTestOperationIds() - lifecycle = [] - statuses = [] - savedListings = [] - // A CLI whose own default is not the catalog's: startup reports it through get_settings, - // since system/init arrives only with the first command. - const claude = fakeClaude({ - initDelayMs: INIT_DELAY_MS, - initModel: 'claude-opus-9', - settings: { - applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, - effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, - sources: {} - }, - // Startup lists from initialize, and a chat's options read from list_models: the same rows. - initModels: CLAUDE_MODELS, - routes: { list_models: () => CLAUDE_MODELS } - }) - adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: root, - claudeConfigDir: join(root, 'claude-home'), - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - // A session that already minted its provider handle resumes it, as the real launch does. - resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, - continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 - }), - // The runtime's own mapping, so this test drives the same lifecycle path production does. - onEvent: (event) => { - const mapped = structuredClaudeLifecycleEvent(event) - if (mapped) { - lifecycle.push(host.handleAdapterEvent(mapped)) - } - }, - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => NOW - }) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - // The production router is what declares create support; the bare adapter only knows locations. - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - modelCatalog: { - read: async () => ({ origin: 'unknown' }), - recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), - prewarm: async () => {}, - stop: () => {}, - providerStarted: () => {} - }, - now: () => NOW - }) - host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function claudeParams(expectedRuntimeFence: number | null = null) { - return hostTestAttachParams(expectedRuntimeFence, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) -} - -function lastPhase(): string | undefined { - const last = statuses.findLast((event) => event.type === 'status') - return last?.type === 'status' ? last.session.hostExecutionPhase : undefined -} - -describe('a publish-first Claude create whose init is slow', () => { - it('keeps provider defaults out of saved picks after starting', async () => { - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - - // Published, not yet answering: the record holds no model rather than a guessed one. - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('ready') - }) - - it('keeps the saved model as intent while starting, then confirms what the child runs', async () => { - const params = claudeParams() - await expect( - host.attach(CALLER, { ...params, options: { model: 'opus' } }) - ).resolves.toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - - it('keeps the picked model across a resume whose new child starts on its own default', async () => { - const params = claudeParams() - await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - await host.close(SESSION, 'evict') - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - // Starting the chat again resumes the session under a new fence. - await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ - ok: true - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child is launched with the saved pick, whatever its CLI default. - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - it('saves the account listing the child read at startup, though no view asks for options', async () => { - await host.attach(CALLER, claudeParams()) - expect(savedListings).toEqual([]) - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - // `started` hands over the listing; the settings readback after it, what the config resolved. - expect(savedListings).toEqual([ - { sessionId: SESSION, listing: expect.objectContaining({ models: LISTED }) }, - { - sessionId: SESSION, - listing: expect.objectContaining({ - models: LISTED, - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - } - ]) - expect(savedListings[0]?.listing).not.toHaveProperty('configuredDefault') - }) - it('reports the configured default from a chat created with its first message and no pick', async () => { - const first = { clientMessageId: 'opening', body: hostTestMessage('hello') } - await expect( - host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) - ).resolves.toMatchObject({ ok: true }) - - // Delivery, not create, starts the CLI. Its `started` event carries the account listing; the - // settings read after it (`options-reported`) resolves the configured model and says so once. - await vi.waitFor(() => expect(savedListings.length).toBeGreaterThanOrEqual(1)) - expect(savedListings[0]).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ models: LISTED }) - }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(savedListings.at(-1)).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - }) - // A later options read lists the models and says nothing more about the default. - await host.readOptions(SESSION) - expect(savedListings.at(-1)?.listing).not.toHaveProperty('configuredDefault') - }) - - it('saves the configured default when the settings readback beats the attach', async () => { - // The attach is slow to prove the owner, so the child's readback lands before the host indexes it. - const proveOwner = store.proveOwner.bind(store) - store.proveOwner = async (input) => { - await new Promise((resolve) => setTimeout(resolve, 200)) - return proveOwner(input) - } - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - await vi.waitFor(() => - expect(savedListings.at(-1)?.listing).toMatchObject({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 2831b63153a7..31b15f6aa742 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -1,9 +1,7 @@ // Opening records the provider handle and recovers history before delivery; reports never replace picks. -import type { - StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionStartedEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsReportedEvent } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, @@ -40,9 +38,15 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } +type OpenedProvider = StructuredAgentSessionProviderOpenResult & { + sessionId: string + fence: number + acquisitionGeneration: string +} + export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent, + event: OpenedProvider, signal?: AbortSignal ): Promise { // Serialized behind the attach that published this child, so the lease it proved is committed. @@ -124,12 +128,12 @@ async function recoverRewindAtStart( * without it a later start could not resume what this child ran. False when there is none. */ async function recordStartedHandle( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: OpenedProvider ): Promise { const { store, logger } = context.deps const { link } = event if (!link) { - // Only an acquisition that already held its handle may prove its start without one. + // Only a spawn that already held its handle may prove its open without one. return store.getRecord(event.sessionId)?.lease.provenHandleLinkId != null } try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index aa5844ef0eea..249d7b6f0a90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -19,7 +19,7 @@ export function adapterSupportsCreate( return adapter.supportsLocation?.(location) ?? false } -/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */ +/** Honors declared gates while retaining adapters whose spawn path is authoritative. */ export function adapterSupportsCreateIfDeclared( adapter: StructuredAgentSessionAdapter, location: AgentSessionExecutionLocation, 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 5a09b26f3b8f..2db4d3ce9600 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 @@ -59,8 +59,20 @@ export function createQueuedRigProvider( async () => true ) let events: StructuredAgentSessionEventSink | undefined + let proveOpen: (() => void) | null = null const scripted: StructuredAgentSessionAdapter = { + open: () => + options.starting + ? new Promise((resolve) => { + proveOpen = () => + resolve({ + reportedOptions: { model: 'default' } + }) + }) + : Promise.resolve({ + reportedOptions: { model: 'default' } + }), acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold @@ -79,7 +91,6 @@ export function createQueuedRigProvider( ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` : (context?.head?.handle.nativeId ?? THREAD) return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -171,6 +182,7 @@ export function createQueuedRigProvider( return { adapter, + proveOpen: () => proveOpen?.(), dispatch, starts, holdNextStart, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f78d27579045..f87063bbefae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -183,7 +183,7 @@ export async function createQueuedMessageTestRig( await result await eventually(async () => { if (host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase === 'starting') { - await emitStarted() + await proveProviderOpen() } expect((await submission(id))?.handedOverAt).toBeDefined() }) @@ -254,23 +254,17 @@ export async function createQueuedMessageTestRig( }) } - /** The starting child proves its start, as a publish-first provider's `started` event does: the - * host hands it what it held. */ + /** The starting child proves its open before the host hands it queued work. */ async function proveStart(): Promise { await eventually(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') ) - await emitStarted() + await proveProviderOpen() } - function emitStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' } - }) + async function proveProviderOpen(): Promise { + provider.proveOpen() + await Promise.resolve() } async function dispose(): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index ff3967c36347..eb079409cff5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -35,7 +35,7 @@ let acquire: Mock const spawnedOwners = new Set>() const supersededHosts = new Set() -async function spawnOwner(spawnToken: string) { +async function acquireOwner(spawnToken: string) { const child = spawnProcess({ program: process.execPath, args: ['-e', 'setInterval(() => {}, 1_000)'], @@ -73,13 +73,16 @@ async function startAgent(): Promise { function adapter(): StructuredAgentSessionAdapter { return { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, - releaseAcquisition: vi.fn(async () => undefined), + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + } } function openHost(overrides: Partial = {}): void { @@ -105,6 +108,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-recovery-exits-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -286,8 +290,9 @@ describe('recovery exits', () => { }) it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { - const outgoing = await spawnOwner('spawn-a') + const outgoing = await acquireOwner('spawn-a') acquire.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: outgoing.process, link: { linkId: 'link-outgoing', @@ -331,8 +336,9 @@ describe('recovery exits', () => { }) expect(acquire).toHaveBeenCalledOnce() - const replacement = await spawnOwner('spawn-b') + const replacement = await acquireOwner('spawn-b') acquire.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: replacement.process, link: { linkId: 'link-replacement', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts deleted file mode 100644 index 72944ea397b6..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionWireRefusalCode -} from '../../../shared/agent-session-wire' -import { - agentSessionRefusalOperationState, - type AgentSessionRefusalOperationState -} from '../../../shared/agent-session-refusal-retry' -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 { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - 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 { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' - -const CALLER = { callerKey: 'client-1' } -const METHODS = ['agentSession.setOption', 'agentSession.send'] as const -type Method = (typeof METHODS)[number] -type Pair = `${Method}:${AgentSessionWireRefusalCode}` - -type CallSpec = { - method: Method - operationId: string - expectedRuntimeFence?: number - payloadFingerprint?: string -} - -type Harness = { - root: string - store: AgentSessionRecordStore - host: StructuredAgentSessionHost - setOption: Mock -} - -const harnesses: Harness[] = [] -let operationSequence = 1_000 - -function operationId(timestamp = NOW): string { - operationSequence += 1 - return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` -} - -async function createHarness(options: { attached?: boolean } = {}) { - const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await openTestAgentSessionRecordStore(root) - const setOption = vi.fn(async () => undefined) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - acquire: async ({ fence }) => ({ - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - // A failed acquisition is proven gone, as the real adapters prove it. - releaseAcquisition: async () => true, - setOption - }, - () => host - ) - const host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const harness = { root, store, host, setOption } - harnesses.push(harness) - if (options.attached !== false) { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - } - return harness -} - -afterEach(async () => { - const completed = harnesses.splice(0) - await Promise.all(completed.map(async ({ host }) => abandonStructuredAgentSessionHost(host))) - await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) -}) - -function callFields(spec: CallSpec): Record { - if (spec.method === 'agentSession.send') { - return { body: hostTestMessage('host oracle') } - } - return { key: 'model', value: 'gpt-5' } -} - -function envelope(harness: Harness, spec: CallSpec): AgentSessionMutationEnvelope { - const fields = callFields(spec) - return { - sessionId: SESSION, - clientOperationId: spec.operationId, - expectedRuntimeFence: - spec.expectedRuntimeFence ?? harness.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: - spec.payloadFingerprint ?? - computeAgentSessionPayloadFingerprint({ method: spec.method, sessionId: SESSION, fields }) - } -} - -function invoke(harness: Harness, spec: CallSpec): Promise> { - const fields = callFields(spec) - const mutationEnvelope = envelope(harness, spec) - if (spec.method === 'agentSession.send') { - return harness.host.send(CALLER, { - envelope: mutationEnvelope, - body: fields.body as ReturnType - }) - } - return harness.host.setOption(CALLER, { - envelope: mutationEnvelope, - key: fields.key as string, - value: fields.value as string - }) -} - -function operationState(harness: Harness, operation: string) { - return harness.store - .listOperationRows() - .find((row) => row.callerKey === CALLER.callerKey && row.operationId === operation)?.outcome -} - -async function assertHostAgreement( - harness: Harness, - spec: CallSpec, - code: AgentSessionWireRefusalCode, - retry?: () => Promise<{ harness: Harness; spec: CallSpec }> -): Promise { - try { - const result = await invoke(harness, spec) - expect(result, `${spec.method}:${code}`).toMatchObject({ ok: false, refusal: { code } }) - } catch (error) { - expect(error).toMatchObject({ message: code }) - } - const outcome = operationState(harness, spec.operationId) - let oracle: AgentSessionRefusalOperationState - if ( - harness.store.readCommandReceipt({ kind: 'global' }, spec.operationId).verdict === 'unreadable' - ) { - oracle = 'unknown' - } else if (outcome?.status === 'failed') { - oracle = 'settled-rejected' - } else if (outcome?.status === 'unknown') { - oracle = 'unknown' - } else if (outcome?.status === 'pending') { - oracle = 'pending-admission' - } else if (retry) { - const next = await retry() - await expect(invoke(next.harness, next.spec)).resolves.toMatchObject({ - ok: true, - replayed: false - }) - oracle = 'pending-admission' - } else { - oracle = 'settled-rejected' - } - expect(agentSessionRefusalOperationState(code), `${spec.method}:${code}`).toBe(oracle) - return `${spec.method}:${code}` -} - -// sendPlan and setOptionPlan have no unsupported branch. -const UNREACHABLE = new Set([ - 'agentSession.send:structured_agent_session_unsupported', - 'agentSession.setOption:structured_agent_session_unsupported', - // performPrompt is the sole producer of prompt revision and resolution refusals. - 'agentSession.setOption:agent_session_item_revision_stale', - 'agentSession.send:agent_session_item_revision_stale', - 'agentSession.setOption:agent_session_already_resolved', - 'agentSession.send:agent_session_already_resolved', - // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. - 'agentSession.setOption:agent_session_identity_required', - 'agentSession.send:agent_session_identity_required', - // Only a send opens the conversation it writes to. - 'agentSession.setOption:agent_session_journal_unreadable', - // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed', - // Saving a next-message pick is a conversation write, independent of its child owner. - 'agentSession.setOption:agent_session_conflict', - 'agentSession.setOption:execution_owner_reconciling', - 'agentSession.setOption:agent_session_operation_unknown', - // A write names its target, not an owner generation; only an attach compares fences. - 'agentSession.setOption:agent_session_checkpoint_stale', - 'agentSession.send:agent_session_checkpoint_stale', - // A send is a conversation write: admitted whoever owns the lease, and a start it needs that - // fails rejects the accepted message rather than refusing the call. - 'agentSession.send:agent_session_conflict', - 'agentSession.send:execution_owner_reconciling', - 'agentSession.send:agent_session_owner_restart_failed', - // Send admits through its receipt, which has no age budget. - 'agentSession.send:agent_session_operation_expired', - // The ledger has no count limit; only an older host still refuses with it. - 'agentSession.setOption:agent_session_operation_capacity', - 'agentSession.send:agent_session_operation_capacity' -]) - -describe('agentSessionRefusalOperationState host oracle', () => { - // Full host retries touch the durable store and successful cases rotate its backup. - it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { - const produced = new Set() - const record = (pair: Pair) => produced.add(pair) - - const stale = await createHarness() - for (const method of METHODS) { - const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } - await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) - } - expect(stale.setOption).not.toHaveBeenCalled() - - const absent = await createHarness({ attached: false }) - for (const method of METHODS) { - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(absent, spec, 'agent_session_ownership_unknown', async () => ({ - harness: await createHarness(), - spec - })) - ) - } - - const operationConflict = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { - record( - await assertHostAgreement( - operationConflict, - { - method, - operationId: operationId(), - payloadFingerprint: 'wrong' - }, - 'agent_session_operation_conflict' - ) - ) - } - const ledgerRefusals = await createHarness() - for (const [code, timestamp, methods] of [ - [ - 'agent_session_operation_expired', - NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, - ['agentSession.setOption'] - ], - ['agent_session_operation_invalid', null, METHODS] - ] as const) { - for (const method of methods) { - record( - await assertHostAgreement( - ledgerRefusals, - { - method, - operationId: timestamp === null ? 'invalid-operation-id' : operationId(timestamp) - }, - code - ) - ) - } - } - - const unknown = await createHarness() - const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } - await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) - openTestJournalHostDatabase(unknown.root) - .db.prepare( - "UPDATE agent_session_command_receipts SET result_json = '{' WHERE operation_id = ?" - ) - .run(sendUnknown.operationId) - record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - - const unreadable = await createHarness() - await unreadable.host.close(SESSION, 'evict') - const unreadableOpen = vi - .spyOn(AgentSessionJournal.prototype, 'open') - .mockRejectedValue(new Error('journal path unreadable')) - const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } - record( - await assertHostAgreement( - unreadable, - unreadableSend, - 'agent_session_journal_unreadable', - async () => { - unreadableOpen.mockRestore() - return { harness: unreadable, spec: unreadableSend } - } - ) - ) - - const allPairs = METHODS.flatMap((method) => - AGENT_SESSION_WIRE_REFUSAL_CODES.map((code) => `${method}:${code}` as Pair) - ) - expect(new Set([...produced, ...UNREACHABLE])).toEqual(new Set(allPairs)) - expect([...produced].filter((pair) => UNREACHABLE.has(pair))).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts deleted file mode 100644 index 8dffa04c3d6b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// The registry is the only source of an agent's definition: routing, declared capabilities and the -// option rules a chat at rest reads all follow what composition registered, and a definition this -// build ships but did not register decides nothing. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLiveOptions -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { - readStructuredAgentSessionOptions, - recordStructuredAgentSessionOptionIntent -} from './structured-agent-session-options-read' - -const RECORD: AgentSessionRecord = { - ...agentSessionRecordFixture(), - options: { model: 'pilot-model' } -} - -/** Claude, registered with rules unlike the ones Claude's own module declares. */ -const NON_DEFAULT: StructuredAgentDefinition = { - ...CLAUDE_STRUCTURED_AGENT, - capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true }, - restingOptions: { - acceptsKey: (key) => key === 'pilotOption', - fallbackModels: () => [ - { id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] } - ], - effortDefaultsToModel: true - } -} - -function fakeAdapter(): StructuredAgentSessionAdapter { - return { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: 1 - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - compact: vi.fn(), - changeThreadGoal: vi.fn() - } -} - -function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) { - const persistOptions = vi.fn(async () => {}) - const result = recordStructuredAgentSessionOptionIntent( - { store: { getRecord: () => record }, agents }, - { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, - { key, value: 'enabled' } - ) - return { result, persistOptions } -} - -/** At rest, or from a running child that answers `live`. */ -function readAtRest(agents: StructuredAgentRegistry, live?: StructuredAgentSessionLiveOptions) { - const resting = { - child: live ? { fence: 1 } : null, - params: { provider: RECORD.provider }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only these members. - const context = { - deps: { - adapter: live - ? { readOptions: async () => live } - : new StructuredAgentSessionAdapterRouter(agents, async () => {}), - agents, - store: { getRecord: () => RECORD } - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, RECORD.sessionId, { readsWithoutModel: true }) -} - -describe('a registered definition', () => { - it('routes, declares capabilities and rules options at rest for its agent', async () => { - const adapter = fakeAdapter() - const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - - await router.acquire({ - identity: { - sessionId: 'session-routed', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-1' - }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) - - const accepted = pick(agents, RECORD, 'pilotOption') - await expect(accepted.result).resolves.toMatchObject({ ok: true }) - expect(accepted.persistOptions).toHaveBeenCalledWith({ - model: 'pilot-model', - pilotOption: 'enabled' - }) - // Claude's own module accepts `model`; the registration says otherwise and wins. - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ ok: false }) - - const options = await readAtRest(agents) - expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) - // No pick for effort: the registered rules read the model's default. - expect(options.current).toEqual({ - model: 'pilot-model', - effort: 'low', - permissionMode: 'ask' - }) - expect(options.threadGoal).toEqual({ current: null }) - }) - - it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { - const agents = new StructuredAgentRegistry([ - { - definition: CODEX_STRUCTURED_AGENT, - adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() } - } - ]) - - expect(agents.definition(RECORD.provider)).toBeNull() - expect(agents.capabilities(RECORD.provider)).toBeNull() - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } - }) - expect(refused.persistOptions).not.toHaveBeenCalled() - const options = await readAtRest(agents) - expect(options.models).toEqual([]) - expect(options.current).toEqual({ model: 'pilot-model' }) - expect(options.conversationCommands).toEqual(['clear']) - expect(options.acceptsImages).toBe(false) - }) - - it('says whether the session takes images: the definition, narrowed by a running child', async () => { - const registry = (imagePrompts: boolean) => - new StructuredAgentRegistry([ - { - definition: { - ...NON_DEFAULT, - capabilities: { ...NON_DEFAULT.capabilities, imagePrompts } - }, - adapter: fakeAdapter() - } - ]) - const live = (acceptsImages?: boolean): StructuredAgentSessionLiveOptions => ({ - models: [], - current: {}, - ...(acceptsImages === undefined ? {} : { acceptsImages }) - }) - await expect(readAtRest(registry(true))).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(false))).resolves.toMatchObject({ acceptsImages: false }) - await expect(readAtRest(registry(true), live())).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(true), live(false))).resolves.toMatchObject({ - acceptsImages: false - }) - // A child's yes never widens a definition's no. - await expect(readAtRest(registry(false), live(true))).resolves.toMatchObject({ - acceptsImages: false - }) - }) - - it('refuses a pick for a session with no record, as before', async () => { - const agents = new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() } - ]) - const missing = pick(agents, null, 'model') - await expect(missing.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(missing.persistOptions).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts deleted file mode 100644 index a06d85abf0ad..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -// What the host does with a second press of the same control, against the real host, store and -// journal: under the first press's id it answers from that press and does nothing more, which is -// why a client sends every press under a new id; under a new id it acts. - -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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionThreadGoalChange } 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 { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let setOption: Mock -let changeThreadGoal: Mock> -let stopBackgroundTasks: Mock> -let cancelTurn: Mock -let providerEvents: Parameters[0]['events'] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-repeat-press-')) - resetHostTestOperationIds() - setOption = vi.fn(async () => undefined) - changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) - cancelTurn = vi.fn(async () => ({ cancelled: true })) - store = await openTestAgentSessionRecordStore(root) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - acquire: async ({ fence, spawnToken, events }) => { - providerEvents = events - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - answerPrompt: vi.fn(async () => undefined), - setOption, - changeThreadGoal, - stopBackgroundTasks - }, - () => host - ) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexAgents(adapter), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - // One live background task, which the background Stops below reach. - statusSink: { - publish: () => {}, - forget: () => {}, - readChildWork: () => [ - { - id: 'child-task-1', - providerId: 'task-1', - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 1, - observedAt: 1, - stoppable: true, - invocation: { invocationId: 'spawn-task-1', generation: 1 } - } - ] - }, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -/** The provider's running turn, which a Stop naming it reaches. */ -async function runningTurn(): Promise { - providerEvents?.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) -} - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function envelope( - method: string, - fields: Record, - clientOperationId: string, - expectedRuntimeFence: number | null = 1 -) { - return { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function pick(value: string, id = hostTestOperationId(), fence: number | null = 1) { - const fields = { key: 'model', value } - return host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields, id, fence), - ...fields - }) -} - -describe('a press sent again', () => { - it('leaves an option on the other pick under the first id, and applies it under a new one', async () => { - const first = hostTestOperationId() - await pick('A', first) - await pick('B') - - expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options?.model).toBe('B') - - expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options?.model).toBe('A') - }) - - it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { - const change = (next: AgentSessionThreadGoalChange, id = hostTestOperationId()) => - host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change: next }, id), - change: next - }) - const set: AgentSessionThreadGoalChange = { kind: 'set', objective: 'Ship the parser' } - const first = hostTestOperationId() - await change(set, first) - await change({ kind: 'clear' }) - - expect(await change(set, first)).toMatchObject({ ok: true, replayed: true }) - expect(changeThreadGoal).toHaveBeenCalledTimes(2) - - expect(await change(set)).toMatchObject({ ok: true, replayed: false }) - expect(changeThreadGoal).toHaveBeenCalledTimes(3) - }) - - it('stops a background task only under a new id once the first Stop ran', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks', taskId: 'task-1' } - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id), - turnId: 'background-tasks', - scope: 'background-tasks' as const, - taskId: 'task-1' - }) - // The first Stop reached the task before it could be stopped; its answer never arrived. - stopBackgroundTasks.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(stopBackgroundTasks).toHaveBeenCalledTimes(2) - }) - - it('interrupts a named turn only under a new id once the first Stop ran', async () => { - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }, id, null), - turnId: 'turn-1' - }) - await runningTurn() - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - await host['serialize'](SESSION, async () => undefined) - expect( - await host.attach(CALLER, hostTestAttachParams(store.getRecord(SESSION)!.lease.runtimeFence)) - ).toMatchObject({ ok: true }) - await runningTurn() - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(cancelTurn).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledTimes(2) - }) - - it('keeps nothing of a press it turned away before running, so its own id runs next time', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks' } - const id = hostTestOperationId() - const stop = (fence: number) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id, fence), - turnId: 'background-tasks', - scope: 'background-tasks' as const - }) - await host.close(SESSION, 'evict') - - // No agent owns the chat, so the host turns the Stop away as not ready. - expect(await stop(1)).toMatchObject({ ok: false }) - const refused = await stop(1) - expect(refused.ok ? null : agentSessionRefusalOperationState(refused.refusal.code)).toBe( - 'pending-admission' - ) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - - expect(await stop(store.getRecord(SESSION)!.lease.runtimeFence)).toMatchObject({ - ok: true, - replayed: false, - value: { cancelled: true } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 37e13a2c71e4..ad1cd60aef82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -43,6 +43,7 @@ export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS export const SWEEP_INTERVAL_MS = 5 export type RestTestAdapter = { + open: StructuredAgentSessionAdapter['open'] acquire: Mock closeSession: Mock> dispatch: Mock @@ -66,11 +67,16 @@ export type RestTestRig = { forget: Mock readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> } - /** Wraps an acquire so its child stays `starting`: every other child proves its start once + /** Wraps a spawn so its child stays `starting`: every other child proves its start once * published. */ heldStart: ( acquire: StructuredAgentSessionAdapter['acquire'] ) => StructuredAgentSessionAdapter['acquire'] + proveStart: (child: { + sessionId: string + fence: number + acquisitionGeneration: string + }) => Promise /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -148,6 +154,9 @@ export async function createRestTestRig( } let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -199,6 +208,7 @@ export async function createRestTestRig( statusEvents, sink, heldStart: starts.held, + proveStart: starts.prove, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index e1a4e14cd0d9..d26f99105a54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -69,7 +69,6 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, - startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts deleted file mode 100644 index c0e8ee04a552..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Startup restore has to publish status, not just index the session. -// -// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a -// session read-only, without a provider child, so the only thing that can surface its state is -// the status publication the restore wiring makes. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -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 { StructuredAgentSessionHost } from './structured-agent-session-host' -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 { 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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function adapter(): StructuredAgentSessionAdapter { - return { - acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - 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 - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { - const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished(adapter(), () => host), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - }), - now: () => NOW - }) - hosts.push(host) - return host -} - -function sendEnvelope( - store: AgentSessionRecordStore, - fields: Record -): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields - }) - } -} - -/** Persists one turn, then hands back a restarted host over the same directories. */ -async function restartWithPersistedTurn(): Promise { - root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - const body = hostTestMessage('persisted conversation') - const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) - if (!sent.ok) { - throw new Error('send was refused') - } - // Delivered, not just accepted: a message still queued at the restart was never a request. - await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) - await host.flushAllStreamedEvents() - return createHost(await openTestAgentSessionRecordStore(root)) -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session restart status publication', () => { - // Served by the subscribe-time re-projection rather than the restore's own publish, so this - // covers what a restored journal projects — not the restore wiring. The test below pins that. - it('projects the persisted turn of a session restored without a provider', async () => { - const restarted = await restartWithPersistedTurn() - - await restarted.restoreReadableSessions() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [ - expect.objectContaining({ - sessionId: SESSION, - workspaceId: 'workspace-1', - agent: 'codex', - status: 'idle', - latestPrompt: 'persisted conversation' - }) - ] - } - ]) - }) - - it('publishes a restored session to a list already sitting on the stream', async () => { - const restarted = await restartWithPersistedTurn() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) - - await restarted.restoreReadableSessions() - - // The restore wiring publishes; without it this list never hears about the session at all. - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index dc71bc09f071..739ad2c5a33f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -20,7 +20,6 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWork: () => undefined, - startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 648f92b6d595..683fe79d9e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -33,7 +33,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] - startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -73,7 +72,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, - startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index b36a7bcb7854..1f050cf80e66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -49,11 +49,13 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts index 88474c453736..35ea3acdfd39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -52,11 +52,13 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index f83e5ef34848..c276eba906f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -73,11 +73,13 @@ beforeEach(async () => { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires.push(input) sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires.length, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts deleted file mode 100644 index 431569f6fa97..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ /dev/null @@ -1,699 +0,0 @@ -// A send, or a hold, that finds the session's provider child gone, against the real host. The -// send is accepted at once; its delivery restarts the child, or rejects it with the reason. - -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 { - AgentSessionMutationEnvelope, - 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 { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' -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 { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let spawnChild: StructuredAgentSessionAdapter['acquire'] -let dispatch: Mock -let hostErrors: unknown[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-send-recovery-')) - resetHostTestOperationIds() - hostErrors = [] - let generation = 0 - spawnChild = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }) - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${dispatch.mock.calls.length}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: startsWhenPublished( - { - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - 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 sendParams(text: string, operationId = hostTestOperationId()) { - const body = hostTestMessage(text) - const envelope: AgentSessionMutationEnvelope = { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - } - return { envelope, body } -} - -/** Accepted at once, before any owner exists for it; answers the message id. */ -async function accept(params: ReturnType): Promise { - const result = await host.send(CALLER, params) - expect(result, JSON.stringify(result)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - return params.envelope.clientOperationId -} - -async function submission(clientMessageId: string) { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -/** The submission once delivery is done with it: handed over, or rejected unwritten. */ -async function settled(clientMessageId: string) { - await eventually(async () => { - const current = await submission(clientMessageId) - expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) - }) - return submission(clientMessageId) -} - -/** The failure rows a start the chat needed left, oldest first. */ -async function errorStatuses(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) -} - -/** The child timed out or exited: its lease is handed back and the host holds no session. */ -async function loseOwner(): Promise { - await host.close(SESSION, 'evict') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - acquire.mockClear() -} - -describe('a send with no live owner', () => { - it('restarts the owner once and delivers against it', async () => { - await loseOwner() - - await accept(sendParams('after the child died')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('accepts the send before anything restarts, and the restart hands it over', async () => { - await loseOwner() - const order: string[] = [] - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - order.push('acquire') - return spawnChild(input) - }) - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementation((args) => { - order.push('admit') - return admit(args) - }) - - await accept(sendParams('accept first')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - - expect(order).toEqual(['acquire']) - }) - - it('leaves a live owner alone', async () => { - acquire.mockClear() - - await accept(sendParams('owner is live')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).not.toHaveBeenCalled() - }) - - it('restarts an independently retained old-build clear source', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: 'session-after-clear', - operationId: hostTestOperationId(), - callerKey: CALLER.callerKey, - phase: 'committed' - } - })) - - await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({ - ok: true - }) - await eventually(() => expect(acquire).toHaveBeenCalledOnce()) - }) - - it('restarts an owner that exited while the session stayed readable', async () => { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence, - acquisitionGeneration: 'generation-1' - }) - expect(host.hasSession(SESSION)).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - - await accept(sendParams('after an exit')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('restarts nothing for a resend the journal already answers', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - // The child died during startup: the lease is handed back, the fence moves, and the session - // stays readable. The client resends against the new fence. - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'startup deadline', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - const resent = { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true }) - - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - // Retry rotates the id: a genuinely new send restarts the owner once. - await accept(sendParams('sent once')) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - }) - - /** A row admitted for this send, then the host died before the journal write, left as `outcome` - * says: `pending` by this build, `unknown` by a build that marked it before running. */ - async function admittedThenHostDied( - params: ReturnType, - outcome: 'pending' | 'unknown' - ) { - await store.admitMutationOperation({ - callerKey: CALLER.callerKey, - envelope: params.envelope, - hostFingerprint: params.envelope.payloadFingerprint, - now: NOW, - operationIdScope: 'global' - }) - if (outcome === 'unknown') { - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) - } - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - return { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - } - - it('runs a send admitted but never run for the first time, restarting the owner once', async () => { - const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts afresh when only an older ledger row exists without a command receipt', async () => { - const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - const spawnChild = acquire.getMockImplementation() - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawn - return spawnChild!(input) - }) - - const first = await accept(sendParams('first')) - await claim - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(lostFence + 1) - // Written against the lost owner's fence, which admits it: a send is a conversation write. - const late = sendParams('second') - late.envelope.expectedRuntimeFence = lostFence - const second = host.send(CALLER, late) - release() - - expect(await second).toMatchObject({ ok: true }) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ - first, - late.envelope.clientOperationId - ]) - }) - - it('shares one restart between concurrent sends', async () => { - await loseOwner() - - const results = await Promise.all([ - host.send(CALLER, sendParams('first')), - host.send(CALLER, sendParams('second')), - host.send(CALLER, sendParams('third')) - ]) - - expect(results.map((result) => result.ok)).toEqual([true, true, true]) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('replays into a closed session without spawning anything', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - await loseOwner() - - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - - // The conversation was opened for the answer; the record's lease was left as it was. - expect(host.hasSession(SESSION)).toBe(true) - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) - const params = sendParams('while signed out') - // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit - // was observed, so the chat does not say the provider stopped. - const cause = "Codex couldn't restart. Send your message to try again." - - const id = await accept(params) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed' } - }) - expect(dispatch).not.toHaveBeenCalled() - // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. - expect( - store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) - ).toMatchObject({ outcome: { status: 'succeeded' } }) - // One row, in the error tone, so the reason outlives the error strip. - expect(await errorStatuses()).toEqual([cause]) - }) - - it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { - await loseOwner() - const said = `no rollout found for thread id ${THREAD}` - acquire.mockRejectedValue( - new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) - ) - const id = await accept(sendParams('after the thread went away')) - - // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. - const rejection = { - kind: 'restartFailed', - detail: { text: said, audience: 'person' }, - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } - } - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Send your message to try again.", - rejection - }) - expect(await errorStatuses()).toEqual([ - "Codex couldn't restart. Send your message to try again." - ]) - // Orca's own text is logged once where the start failed. - expect( - hostErrors.filter( - (error) => error instanceof Error && error.message === `thread/resume failed: ${said}` - ) - ).toHaveLength(1) - }) - - it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const params = sendParams('while signed out') - await settled(await accept(params)) - expect(acquire).toHaveBeenCalledTimes(1) - - // A client that resends the same id gets the recorded rejection, and the chat no second row. - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { dispatchState: 'rejected' } } - }) - expect(acquire).toHaveBeenCalledTimes(1) - expect(await errorStatuses()).toHaveLength(1) - - // The outbox's Retry rotates the id: a fresh attempt, with its own row. - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toHaveLength(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('restarts and delivers a later send once the cause clears', async () => { - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - - // The user signed in; nothing about the failed attempt is remembered. - await accept(sendParams('signed in now')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('suggests a new chat only when this host has nothing to restart the chat from', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const failed = await settled(await accept(sendParams('restart fails'))) - expect(failed).toMatchObject({ dispatchState: 'rejected' }) - expect(failed?.reason).not.toMatch(/new chat/) - - // The adapter cannot run this record where it lives: no retry would bring it back. - host.deps.adapter.supportsLocation = () => false - const unresumable = await settled(await accept(sendParams('cannot resume here'))) - - expect(unresumable).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Start a new chat to continue.", - rejection: { - kind: 'restartFailed', - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - } - }) - }) - - it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockResolvedValueOnce({ - ok: false, - refusal: { - code: 'execution_owner_reconciling', - message: 'Another runtime is still adjudicating this lease.' - } - }) - - const id = await accept(sendParams('owner being settled')) - - // The refusal's prose stays out of the chat; its code rides in the fact. - const cause = "Codex couldn't restart. Send your message to try again." - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } - }) - expect(acquire).not.toHaveBeenCalled() - expect(await errorStatuses()).toEqual([cause]) - }) - - it('rejects the message, and reports the fault, when the restart itself faults', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockRejectedValueOnce(new Error('spawn-token mint failed')) - - const id = await accept(sendParams('bookkeeping failed')) - - // Orca's own fault: reported to the log, and the chat says only that Orca failed. - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Orca ran into a problem, so this didn't go through. Try again.", - rejection: { kind: 'hostFault' } - }) - expect(hostErrors).toContainEqual( - expect.objectContaining({ message: 'spawn-token mint failed' }) - ) - expect(await errorStatuses()).toHaveLength(1) - }) - - it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { - await loseOwner() - // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await accept(sendParams('latched owner')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'live' - }) - }) - - it('adjudicates a lease this host has not reconciled before its delivery resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - - // The send's start is the same serialized resume a hold runs, reconciliation first. - await accept(sendParams('owner unverified')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) - }) -}) - -// A pane keeps the fence of the last frame it read. An idle release and the restart after it -// each move the lease, so that fence can be several generations behind the one a write lands on. -describe('a write fenced to an owner the pane has not seen replaced', () => { - it('delivers a send fenced to the owner an idle release retired', async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await loseOwner() - const params = sendParams('after the release') - params.envelope.expectedRuntimeFence = seenFence - - const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) - - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - }) - - // Clients resend a refused message when the fence they hold moves. A failed start is a - // rejected message now, never a refused send, and the pane keeps the fence it subscribed under. - it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const frames: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - const subscribed = frames.length - - const id = await accept(sendParams('while signed out')) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'restartFailed' } - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - const published = frames.slice(subscribed) - expect(published.length).toBeGreaterThan(0) - for (const frame of published) { - expect(frame).toMatchObject({ fence: seenFence }) - } - }) - - it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawn - return spawnChild(input) - }) - - const firstParams = sendParams('starts the agent') - const first = host.send(CALLER, firstParams) - await claim - const cancelFields = { turnId: 'turn-1' } - const stop = host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: lostFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: cancelFields - }) - }, - ...cancelFields - }) - const late = sendParams('typed during the start') - late.envelope.expectedRuntimeFence = lostFence - 1 - const second = host.send(CALLER, late) - release() - - expect(await first).toMatchObject({ ok: true }) - expect(await stop).toMatchObject({ ok: true, replayed: false }) - expect(await second).toMatchObject({ ok: true, replayed: false }) - // The Stop withdrew the message its start held; the one typed after it is delivered. - expect(await settled(late.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'accepted' - }) - expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'rejected' - }) - // The Stop ended the child still proving its start, so the late send started another. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index ae7359e7566f..015c6607a544 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho 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 { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -44,6 +45,7 @@ const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +const starts = structuredAgentSessionHeldStarts(() => host) let acquire: Mock let dispatch: Mock let generation = 0 @@ -98,11 +100,7 @@ function currentChild() { } function proveStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - ...currentChild(), - reportedOptions: { model: 'sonnet' } - }) + return starts.prove(currentChild()) } function exitBeforeProof(): Promise { @@ -137,7 +135,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -155,15 +152,18 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, + adapter: starts.wrap({ + acquire: starts.held(acquire), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + setOption: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' } + }) + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, @@ -175,6 +175,7 @@ beforeEach(async () => { }) afterEach(async () => { + starts.clear() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts deleted file mode 100644 index 4377cf7b4840..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -// What a client's retry sees around a failed attach settlement: a crash between -// reserve and settlement replays into the original reservation, and a settled -// failure refuses sends without ever re-dispatching on the user's behalf. - -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 { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -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 { AgentSessionJournal } from '../agent-session-journal/journal-store' -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 { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) - -vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - writeAgentSessionStoreRows: (...args: Parameters) => { - publishFault.publishCount += 1 - if (publishFault.publishCount === publishFault.failOnPublish) { - throw new Error('simulated crash before failed-settlement publish') - } - return actual.writeAgentSessionStoreRows(...args) - } - } -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let releaseAcquisition: Mock> -let dispatch: Mock - -function accepted(): AgentSessionDispatchOutcome { - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } -} - -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - acquire, - releaseAcquisition, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settled-attach-retry-')) - publishFault.failOnPublish = 0 - publishFault.publishCount = 0 - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - releaseAcquisition = vi.fn(async () => true) - dispatch = vi.fn(async () => accepted()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - publishFault.failOnPublish = 0 - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The host starting the agent with no message to deliver, as an operation that needs it does. */ -function startAgent(): Promise { - return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) -} - -describe('settled attach retry', () => { - it('settles a post-acquisition journal failure and retries without a restart', async () => { - const journalDatabase = openTestJournalHostDatabase(root) - vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( - new Error('journal path unavailable') - ) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const first = hostTestAttachParams(null) - - await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - reservedSpawnToken: null, - runtimeFence: 2 - }) - expect( - store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) - ?.outcome - ).toMatchObject({ status: 'failed' }) - - await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - }) - - it('continues a safe same-host pending replay with the reserved token', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - providerChildPhase: 'starting' as const, - 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 - } - } - }) - const mintSpawnToken = vi.fn(() => 'spawn-safe') - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(mintSpawnToken).toHaveBeenCalledOnce() - expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) - }) - - it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - providerChildPhase: 'starting' as const, - 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 - } - } - }) - let token = 0 - const mintSpawnToken = vi.fn(() => `spawn-${++token}`) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(publishFault.publishCount).toBe(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null - }) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - - await host.restoreReadableSessions() - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - // No owner was recorded: released at restart, with no evidence, since nothing proved one. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 2, - reservedSpawnToken: null, - ownerProcess: null, - deathEvidence: null - }) - - // The interrupted operation's own retry continues it as a fresh reservation. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - expect(mintSpawnToken).toHaveBeenCalledTimes(2) - expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - runtimeFence: 3, - handoffStage: null, - handoffOperationId: null, - ownerProcess: { spawnToken: 'spawn-2' } - }) - }) - - it('settles a submission the host restart left pending, and never redelivers it', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - // Admitted: written to the child, acknowledgement still outstanding. The - // restart below is the process fact that ends the wait, not a stopwatch. - dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) - const body = hostTestMessage('written before the host died') - const unknownParams = { - envelope: envelope('agentSession.send', { body }), - body - } - const first = await host.send(CALLER, unknownParams) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Handed over before the host dies: that is what makes the restart's answer doubt. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-restarted', - probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW - }) - await host.restoreReadableSessions() - await startAgent() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - expect(dispatch).toHaveBeenCalledTimes(1) - - const newBody = hostTestMessage('are you there?') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body: newBody }), - body: newBody - }) - if (!sent.ok) { - throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) - } - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!restoredHistory.ok) { - throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) - } - expect( - restoredHistory.page.submissions.find( - (submission) => submission.clientMessageId === unknownParams.envelope.clientOperationId - )?.dispatchState - ).toBe('unknown') - - // A restart ends the wait without proving the dead child never took the - // frame, so even an explicit retry replays rather than sending a second copy. - const explicitRetry = await host.send(CALLER, { - ...unknownParams, - envelope: { - ...unknownParams.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 3 - }, - retryUnknown: true - }) - expect(explicitRetry).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } - }) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('records proven acquisition cleanup as durable death evidence', async () => { - acquire.mockRejectedValueOnce(new Error('resume rejected')) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: false, - refusal: { - message: "Codex couldn't restart. Send your message to try again.", - ownerVerdict: 'exited' - } - }) - - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { - kind: 'exit-observed', - detail: 'acquisition cleanup proved no provider child remains' - } - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index c4f08c6a71c0..d90943123b01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,5 +1,3 @@ -import { failedProviderChildStart } from './structured-agent-session-provider-child' -import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -13,6 +11,27 @@ import type { import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' + +/** A queued message whose child died while it waited inherits that child's failure. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: AgentJournalSubmission +): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} export function refusedStructuredAgentSessionStart( record: AgentSessionRecord | null, @@ -108,22 +127,3 @@ export function oldestQueuedSubmission( } return oldest } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -export function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index f71343d75d58..3bc7b80506b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -117,9 +117,9 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: () => new Promise(() => {}), acquire: vi.fn(async ({ fence, spawnToken }) => { const child = { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 14767b058d1f..274ffb6f208a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -31,13 +31,13 @@ export type StructuredAgentSessionStartupAttempt = { readonly options?: Readonly> /** The permission mode the start launches with; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode - /** Provider events may begin before acquisition returns. */ + /** Provider events may begin before spawn returns. */ readonly events?: StructuredAgentSessionEventSink /** Background work this start's child reports, scoped to the attempt. Unset until the host routes * child work by attempt; adapters report through their registration until then. */ readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void /** Aborted by a close, a Stop admitted now, quit, or the startup limit: the adapter stops what it - * started and the acquire fails. */ + * started and spawn fails. */ readonly signal?: AbortSignal /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void 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 8e60ca568978..707d01677ba6 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 @@ -48,10 +48,17 @@ let closeSession: Mock let startupLimits: Partial | undefined const providerStarted = vi.fn() +const openGates = new Map void>() + +async function proveOpen(model = 'default'): Promise { + const generation = `generation-${acquire.mock.calls.length}` + await eventually(() => expect(openGates.has(generation)).toBe(true)) + openGates.get(generation)!(model) + openGates.delete(generation) +} function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -69,7 +76,7 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq } } -/** An acquire that publishes at spawn, as every one does: the child proves its start later. */ +/** A spawn that publishes its child before open proves readiness. */ const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) async function startHost(): Promise { @@ -78,6 +85,10 @@ async function startHost(): Promise { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: ({ acquisitionGeneration }) => + new Promise((resolve) => { + openGates.set(acquisitionGeneration!, (model) => resolve({ reportedOptions: { model } })) + }), acquire, dispatch, closeSession, @@ -119,6 +130,7 @@ beforeEach(async () => { resetHostTestOperationIds() startupLimits = undefined providerStarted.mockReset() + openGates.clear() acquire = vi.fn(async (input) => acquisition(input)) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -177,16 +189,6 @@ async function submission(id: string): Promise { const id = await accept(text) @@ -220,7 +222,7 @@ const HOST_STOPPED = agentSessionFailureWords(agentSessionFailureFact('hostStopp }) describe('a send while the agent starts', () => { - it('is accepted at once and handed over only once the start proves itself', async () => { + it('keeps a spawned child unopened until required protocol open proves readiness', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') @@ -228,7 +230,7 @@ describe('a send while the agent starts', () => { expect((await submission(id))?.handedOverAt).toBeUndefined() expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(1) @@ -241,37 +243,19 @@ describe('a send while the agent starts', () => { // The same id again is the same message: it is recorded and handed over once. await accept('first', first) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, second]) }) - it('ignores a start proof from a child it no longer holds', async () => { - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.handleAdapterEvent({ ...startedEvent(), fence: startedEvent().fence + 1 }) - await host.flushStreamedEvents(SESSION) - - expect(dispatch).not.toHaveBeenCalled() - expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting') - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - }) - it('tells the model catalog once the start proves itself, never for a child it no longer holds', async () => { await restartWith(publishFirst) providerStarted.mockClear() const id = await heldBehindStart('hello') expect(providerStarted).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.flushStreamedEvents(SESSION) - expect(providerStarted).not.toHaveBeenCalled() - - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(providerStarted).toHaveBeenCalledTimes(1) expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) @@ -310,6 +294,8 @@ describe('a start that ends before it proves itself', () => { }) expect((await submission(id))?.handedOverAt).toBeUndefined() expect(closeSession).toHaveBeenCalled() + const rows = (await host.journalSnapshot(SESSION)).items.map(({ body }) => body) + expect(rows).toContainEqual({ kind: 'status', text: 'Cancellation requested.' }) expect(dispatch).not.toHaveBeenCalled() expect(startupAttemptOpen()).toBe(false) }) @@ -409,9 +395,9 @@ describe('a start that ends before it proves itself', () => { it('is not ended by the limit once it proved itself', async () => { startupLimits = { silenceMs: 50 } await restartWith(publishFirst) - const id = await heldBehindStart('hello', 0) - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted'), 0) + const id = await heldBehindStart('hello') + await proveOpen() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await vi.advanceTimersByTimeAsync(100) @@ -425,11 +411,13 @@ describe('what a started child reports', () => { it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reportedOptions: { model: 'from-settings' } }) await host.flushStreamedEvents(SESSION) @@ -445,11 +433,13 @@ describe('what a started child reports', () => { options: { model: 'picked' }, now: NOW }) - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reportedOptions: { model: 'from-settings' } }) await host.flushStreamedEvents(SESSION) @@ -489,7 +479,7 @@ describe('a chat after startup permission backfill', () => { await restartWith(publishFirst) await heldBehindStart('hello') expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await vi.advanceTimersByTimeAsync(50) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 936a2bd202f8..cd1bf63a09c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -71,7 +71,6 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ spawnToken: input.spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - providerChildPhase: 'starting' as const, link: { linkId: `link-${input.fence}`, handle: handle(definition), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts deleted file mode 100644 index 841b35825589..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS as CEILING_MS, - STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS as SILENCE_MS -} from './structured-agent-session-startup-attempt-contract' -import { - StructuredAgentSessionStartupAttempts, - mintStructuredAgentSessionStartupAttempt, - type StructuredAgentSessionExpiredStartup, - type StructuredAgentSessionStartupSettled -} from './structured-agent-session-startup-attempt' - -const SESSION = 'session-alpha' -const CHILD = { generation: 'generation-1', fence: 3 } - -let expired: StructuredAgentSessionExpiredStartup[] -let settled: StructuredAgentSessionStartupSettled[] -let attempts: StructuredAgentSessionStartupAttempts - -function mint() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mint reads only the lease fence, location, account home and options this literal sets. - const record = { - lease: { runtimeFence: CHILD.fence }, - location: { executionHostId: 'local', workspaceId: 'workspace-1' }, - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } - } as unknown as AgentSessionRecord - return mintStructuredAgentSessionStartupAttempt({ - record, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: null - }, - spawnToken: 'spawn-1' - }) -} - -/** Output every `everyMs` for `forMs`. */ -function chatter(output: () => void, forMs: number, everyMs = 10_000): void { - for (let elapsed = 0; elapsed < forMs; elapsed += everyMs) { - vi.advanceTimersByTime(everyMs) - output() - } -} - -beforeEach(() => { - vi.useFakeTimers() - expired = [] - settled = [] - attempts = new StructuredAgentSessionStartupAttempts({ - expire: (startup) => expired.push(startup), - settled: (startup) => settled.push(startup) - }) -}) - -afterEach(() => { - attempts.dispose() - vi.useRealTimers() -}) - -describe('the startup clock', () => { - it('does not run before the spawn: resolving a launch is not starting', () => { - attempts.track(SESSION, mint()) - - vi.advanceTimersByTime(CEILING_MS * 2) - - expect(expired).toEqual([]) - }) - - it('fails a spawned agent that says nothing for the silence limit, naming the acquire', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: null }]) - vi.advanceTimersByTime(CEILING_MS) - expect(expired).toHaveLength(1) - // Measured at the limit, recorded once the aborted acquire settles. - expect(settled).toEqual([]) - attempts.abandon(SESSION, attempt.attemptId) - expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) - }) - - it('records a start that turned ready as the limit fired as ready', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(expired).toHaveLength(1) - - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'ready' }) - - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS }]) - }) - - it('keeps a slow start that is still talking past the silence limit', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - chatter(progress.output, SILENCE_MS * 3) - attempts.ready(SESSION, CHILD) - - expect(expired).toEqual([]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS * 3 }]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('times silence from the last output, not the spawn', () => { - const progress = attempts.track(SESSION, mint()) - progress.spawned() - vi.advanceTimersByTime(SILENCE_MS - 1_000) - progress.output() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toHaveLength(1) - }) - - it('fails a chatty agent that never becomes ready at the ceiling', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - chatter(progress.output, CEILING_MS - 10_000) - expect(expired).toEqual([]) - chatter(progress.output, 10_000) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: CHILD }]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ceiling', durationMs: CEILING_MS }]) - }) - - it('starts on output or publication when no spawn was reported', () => { - const byOutput = mint() - attempts.track(SESSION, byOutput).output() - const byPublish = mint() - attempts.track('session-beta', byPublish) - attempts.published('session-beta', byPublish.attemptId, { ...CHILD, phase: 'starting' }) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired.map((startup) => startup.attemptId).sort()).toEqual( - [byOutput.attemptId, byPublish.attemptId].sort() - ) - }) -}) - -describe('an attempt ends', () => { - it('with the start its child proved, and ignores a stale child’s proof', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.ready(SESSION, CHILD) - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('with its child, however the child ended, leaving no entry or timer', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.childEnded(SESSION, CHILD) - - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - expect(settled.map((startup) => startup.outcome)).toEqual(['ended']) - }) - - it('once an expiry has stopped its published child', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired).toHaveLength(1) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when an acquire the expiry aborted is abandoned, or publishes late and is expired then', () => { - const abandoned = mint() - attempts.track(SESSION, abandoned).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.abandon(SESSION, abandoned.attemptId) - expect(attempts.isOpen(SESSION)).toBe(false) - - const late = mint() - attempts.track(SESSION, late).spawned() - vi.advanceTimersByTime(SILENCE_MS) - attempts.published(SESSION, late.attemptId, { ...CHILD, phase: 'starting' }) - - expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - // Each start is measured once, at the limit that ended it. - expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) - }) - - it('with an acquire that answered ready, or an attach that failed', () => { - const ready = mint() - attempts.track(SESSION, ready).spawned() - attempts.published(SESSION, ready.attemptId, { ...CHILD, phase: 'ready' }) - const failed = mint() - attempts.track('session-beta', failed).spawned() - attempts.abandon('session-beta', failed.attemptId) - - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when the next attempt replaces it, and every clock stops at quit', () => { - const first = mint() - const firstProgress = attempts.track(SESSION, first) - firstProgress.spawned() - const second = mint() - attempts.track(SESSION, second).spawned() - // The replaced attempt's reports reach nothing. - firstProgress.output() - attempts.abandon(SESSION, first.attemptId) - expect(attempts.isOpen(SESSION)).toBe(true) - - attempts.dispose() - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index 4b16f3f12ad6..90bbb3680299 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -58,11 +58,11 @@ export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' } -/** Where an expired attempt stood: still inside its acquire, or published as a starting child. */ +/** Where an expired attempt stood: still spawning, or published as a starting child. */ export type StructuredAgentSessionExpiredStartup = { sessionId: string attemptId: string - /** Null while the acquire has not returned. */ + /** Null while spawn has not returned. */ child: StructuredAgentSessionProviderChildIdentity | null } @@ -74,7 +74,7 @@ export type StructuredAgentSessionStartupSettled = { durationMs: number } -/** What the host learns about a start while its acquire runs. */ +/** What the host learns about a start while spawn runs. */ export type StructuredAgentSessionStartupProgress = { /** The process exists: the clock starts. */ spawned: () => void @@ -89,7 +89,7 @@ type Tracked = { lastOutputAt: number timer: ReturnType | null expired: boolean - /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway + /** The limit's measure, held until an aborted spawn settles: one that is ready anyway * reports that instead. */ expiry: StructuredAgentSessionStartupSettled | null readiness: PromiseWithResolvers<'ready' | 'ended'> @@ -105,7 +105,7 @@ export class StructuredAgentSessionStartupAttempts { constructor( private readonly deps: { - /** Runs outside the session's queue: an acquire may hold it for the whole handshake. */ + /** Runs outside the session's queue: spawn or open may hold it through the handshake. */ expire: (expired: StructuredAgentSessionExpiredStartup) => void /** Never throws back into the clock. */ settled?: (settled: StructuredAgentSessionStartupSettled) => void @@ -285,7 +285,7 @@ export class StructuredAgentSessionStartupAttempts { } } - /** `reason` null: a start whose limit already passed inside its acquire was published now. */ + /** `reason` null: a start whose limit already passed inside spawn was published now. */ private expire(sessionId: string, tracked: Tracked, reason: 'silent' | 'ceiling' | null): void { if (this.open.get(sessionId) !== tracked || this.disposed) { return @@ -295,7 +295,7 @@ export class StructuredAgentSessionStartupAttempts { } tracked.expired = true // A published child's stop is under way and ends nothing a newer attempt owns; one still - // acquiring stays tracked until its aborted acquire is abandoned or published. + // spawning stays tracked until its aborted spawn is abandoned or published. if (tracked.child) { this.report(tracked.expiry) this.end(sessionId) 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 0b51d86d6e80..2c0a796375dc 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 @@ -51,6 +51,9 @@ async function stopWhileTheTurnOpens( agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), 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 a710918fea32..d6a1f8450084 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 @@ -79,6 +79,9 @@ async function stopEndingTheChild(options: { agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), 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 040a8fc78b34..f806875934f6 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 @@ -137,6 +137,8 @@ it('is saved before it interrupts, and a retry of its id never stops the newer t expect((await rig.submission(behind))?.dispatchState).not.toBe('rejected') expect((await rig.submission(newer))?.dispatchState).not.toBe('rejected') expect(await rig.drafts()).toEqual([]) + expect(await rig.stop()).toMatchObject({ ok: true, replayed: false, value: { cancelled: true } }) + expect(rig.cancelTurn).toHaveBeenCalledTimes(2) }) // Round 1: a late Stop acted on newer work while the next message was handed over. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts deleted file mode 100644 index 3dd914f647fa..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ /dev/null @@ -1,873 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The lifetime of a provider child, against the real host rather than a double. -// -// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that -// starts one for every record on disk. A view never starts or keeps a child; work starts one, the -// idle sweep stops it, and an exit is settled and left for the next send. - -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 type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - 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 { - AgentSessionAcquisitionRootExitObservedError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' -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 { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'Codex stopped while this response was in progress. You can continue in this conversation.' - -const CALLER = { callerKey: 'client-1' } -/** Short enough to keep the suite fast; the host clock below decides what is idle. */ -const SWEEP_MS = 5 -const IDLE_MS = 1_000 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let closeSession: Mock> -let dispatch: Mock -let sink: StructuredAgentSessionEventSink | null -let hostErrors: unknown[] -let statusSink: StructuredAgentSessionStatusSink -let clock: number -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - acquire, - closeSession, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function openHost( - probeOwner?: (record: AgentSessionRecord) => Promise -): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, - now: () => clock, - statusSink, - ...(probeOwner ? { probeOwner } : {}) - }) -} - -/** A fresh app generation over the same durable store, with its owner proven gone. */ -async function reboot(): Promise { - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() - closeSession.mockClear() -} - -async function attach(): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -} - -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ -async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -/** The sweep stops the child first and closes the conversation last. */ -function waitForEviction(): Promise { - return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - }) -} - -/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ -function waitOutSeveralSweeps(): Promise { - return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) -} - -/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The - * stop drains once before it, to judge whether it ends work. */ -function failEvictionDrain(): void { - const sink = host['runtimeState'].eventSinkFor(SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce(drained) - .mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') }) -} - -/** The submissions as they stood when the session was forgotten; its journal is gone after that. */ -async function failJournalSinkUntilReleased(): Promise { - const session = ( - host as unknown as { - sessions: Map Promise } }> - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed' } - }) - }) -} - -/** Replaces the failed cached sink so suite cleanup can drain the host. */ -function replaceFailedSink(): void { - ;( - host as unknown as { - runtimeState: { eventSinkFor: (sessionId: string) => unknown } - } - ).runtimeState.eventSinkFor(SESSION) -} - -function captureSettledSubmissions(): { value: AgentJournalSubmission[] } { - const captured: { value: AgentJournalSubmission[] } = { value: [] } - const journal = host['sessions'].get(SESSION)!.journal - const closeJournal = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - captured.value = journal.snapshot().submissions - await closeJournal() - }) - return captured -} - -async function sendPending(text: string): Promise { - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage(text) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) - resetHostTestOperationIds() - sink = null - hostErrors = [] - clock = NOW - statusSink = { publish: vi.fn(), forget: vi.fn() } - let generation = 0 - acquire = vi.fn(async ({ fence, spawnToken, events }) => { - sink = events ?? null - return { - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ - state: 'rejected' as const, - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - })) - store = await openTestAgentSessionRecordStore(root) - openHost() -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat that closes', () => { - it('stops the provider child it started', async () => { - await attach() - - await host.close(SESSION, 'evict') - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(hostErrors).toEqual([]) - // The record and its journal stay; only the process and the claim on it go. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - }) - - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted. A read in that window reopens the conversation as a cache - // and is answered, never refused; it starts nothing. - it('answers a read from the pane that outlived it without starting a child', async () => { - await attach() - - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - const unsubscribe = await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: () => undefined - }) - unsubscribe() - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a compatibility wait with what eviction recorded', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until close') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Handed over first: a message still queued at close is rejected as never sent instead. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - - await host.close(SESSION, 'evict') - - // Eviction's settlement is a journal write, so the wait sees it rather than timing out. - await expect(settlement).resolves.toMatchObject({ - value: { - submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } - } - }) - }) - - it('keeps the stop it made when the journal close loses its result', async () => { - await attach() - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - const closeJournal = session!.journal.close.bind(session!.journal) - vi.spyOn(session!.journal, 'close') - .mockImplementationOnce(async () => { - await closeJournal() - throw new Error('journal close result lost') - }) - .mockImplementation(closeJournal) - - // The child is stopped and the lease released before the handle closes; the entry is dropped - // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeSession).toHaveBeenCalledOnce() - }) - - it('reports a failed drain after the child stopped, and still settles and releases', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending across an aborted eviction') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - failEvictionDrain() - const settled = captureSettledSubmissions() - - // The child is proven gone: the failed drain is bookkeeping, reported, and the rest still runs. - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(session!.child).toBeNull() - expect(closeSession).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) - -describe('a session with a turn in flight', () => { - it('is not stopped while the turn runs, and is once it ends and idles', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - - clock += IDLE_MS - await waitOutSeveralSweeps() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - emitTurnLifecycle('completed', 2) - await host.flushStreamedEvents(SESSION) - clock += IDLE_MS - - await waitForEviction() - }) - - // Codex settles an admitted send only on its echo, which may never come; the stop retires it. - it('is stopped with an admitted send outstanding once no turn runs', async () => { - await attach() - await sendPending('admitted, never echoed') - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - - clock += IDLE_MS - - await waitForEviction() - }) -}) - -describe('startup', () => { - it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { - await attach() - const beforeRestart = store.getRecord(SESSION) - await abandonStructuredAgentSessionHost(host) - - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() - - const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( - false - ) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await startAgent() - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( - beforeRestart?.providerHandleChain.at(-1)?.handle - ) - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.origin).toBe('resumed') - }) - - it('restores a session for reading without spawning a provider child', async () => { - await attach() - await reboot() - - await host.restoreReadableSessions() - - // The record is readable — the tab comes back, history answers — and nothing is running. - expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ - { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } - ]) - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('gives the child back for work, never for a read', async () => { - await attach() - await reboot() - await host.restoreReadableSessions() - - const unsubscribe = await host.subscribe({ - id: 'viewer-1', - sessionId: SESSION, - emit: () => undefined - }) - expect(acquire).not.toHaveBeenCalled() - await startAgent() - unsubscribe() - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - ownerProcess: { pid: 4242 } - }) - }) -}) - -describe('a session closed and started again', () => { - it('publishes provider events to the reattached chat', async () => { - await attach() - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - await startAgent() - const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - sink?.publish() - await host.flushStreamedEvents(SESSION) - unsubscribe() - - expect(JSON.stringify(events)).toContain('back again') - }) -}) - -async function submissionState(clientMessageId: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - )?.dispatchState -} - -describe('an unexpected provider exit', () => { - it('publishes terminal settlement to a waiting older client', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until provider exit') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Accepted first; the exit must meet a message the provider was handed. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - await expect(settlement).resolves.toMatchObject({ - value: { submission: { dispatchState: 'unknown' } } - }) - }) - - it('turns a journal sink failure into observed-exit settlement and lease release', async () => { - await attach() - - await failJournalSinkUntilReleased() - - expect(dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - history.ok && - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.text.includes('journal sink failure') - ) - ).toBe(false) - replaceFailedSink() - }) - - // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces. - it('stops a provider whose output would not read back, and the next send starts a child', async () => { - await attach() - - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) - }) - expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' })) - replaceFailedSink() - - // Nothing restarts it on its own; the next send does, and the chat still loads. - expect(acquire).toHaveBeenCalledOnce() - await startAgent() - expect(acquire).toHaveBeenCalledTimes(2) - emitTurnLifecycle('running', 2) - await host.flushAllStreamedEvents() - await reboot() - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - }) - - it('settles a journal sink failure whose stop saw the provider root exit', async () => { - await attach() - // The lease follows the root, so its seen exit settles like a proven one. - closeSession.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error('provider close unproven')) - ) - - await failJournalSinkUntilReleased() - - replaceFailedSink() - }) - - it('releases the exact generation, starts nothing, and the next message starts a child', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) - await expect( - host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. - await vi.waitFor(async () => - expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') - ) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - recoveredHistory.ok && - hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) - ).toBe(false) - // No respawn: the exit is settled and shown, and the conversation waits for work. - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('does not reacquire for a live reader or a stale child generation', async () => { - await attach() - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: () => undefined - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'stale child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-stale' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'current child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed' } - }) - unsubscribe() - }) - - it('ends the record of a requested close as a close, never as a crash to recover from', async () => { - await attach() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider closed', - cause: 'requested-close', - fence, - acquisitionGeneration: 'generation-1' - }) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownParams = { - envelope: envelope('agentSession.send', { body: unknownBody }), - body: unknownBody - } - await expect(host.send(CALLER, unknownParams)).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') - ) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') - // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — its failure fact beside it, and never the old bare `Provider exited: ` row. - const statuses = history.ok - ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - : [] - expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) - expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message after failed-barrier recovery') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const session = ( - host as unknown as { - sessions: Map< - string, - { journal: { appendLifecycleBatch: (...args: never[]) => Promise } } - > - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - // The conversation's one handle refuses every write of the exit's settlement. - const refusing = vi - .spyOn(session!.journal, 'appendLifecycleBatch') - .mockRejectedValue(new Error('settlement still unavailable')) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const released = store.getRecord(SESSION)?.lease - expect(released).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } - }) - // The retry recording the exit queues is refused too; once the journal writes again, the next - // acquire re-derives it. - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - refusing.mockRestore() - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('sent after a settlement that never landed') - const sentEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: sentEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - const items = history.ok ? history.page.items : [] - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) - ) - const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - expect(statuses).toContainEqual({ - kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' }, - tone: 'error' - }) - expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') - }) -}) - -describe('a quit after a close whose drain failed', () => { - // The close's own wind-down hands the lease back past a failed drain, so quit finds nothing owed. - it('has nothing of that child left to finish', async () => { - await attach() - await sendPending('pending across a failed drain') - const settled = captureSettledSubmissions() - failEvictionDrain() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() - - await host.flushAllStreamedEvents() - - expect(closeSession).toHaveBeenCalledOnce() - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 6727b0144c03..687418e80c81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -132,14 +132,17 @@ function openHost(overrides: Partial = {}): void logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, - releaseAcquisition: vi.fn(async () => undefined), + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn(), supportsCreate: () => true - } as unknown as StructuredAgentSessionAdapter, + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', @@ -158,6 +161,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -695,6 +699,7 @@ describe('already-wedged profiles become usable on load', () => { acquire.mockImplementation(async ({ fence }) => { order.push('acquire') return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index fe724185de34..2a2e13e19c2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -52,6 +52,7 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the * projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */ @@ -164,7 +165,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - child: { fence: number; phase?: 'starting' | 'ready' } | null + child: Pick | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -175,8 +176,6 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined - /** False when the session's live child never answered its start; the exit reads the same fact. */ - startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -190,8 +189,7 @@ export function structuredAgentSessionWorkingAtStop(input: { const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions) // A queued message reached no agent, and one handed to a start that never answered ran nowhere: // neither is work to resume. The next open or the exit keeps it as unsent. - const unanswered = - session.child.phase === 'starting' || input.startAnswered?.(sessionId) === false + const unanswered = session.child.phase === 'starting' const handedOver = current.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) && diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 7191ada60a86..12451d92a4c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -103,21 +103,13 @@ beforeEach(async () => { adapter = { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async (input) => { acquisitions++ const acquisitionGeneration = `acquisition-${acquisitions}` - const starter = host - // The handshake answers at once, reporting what the child runs with. - void starter.handleAdapterEvent({ - type: 'started', - sessionId: input.identity.sessionId, - fence: input.fence, - acquisitionGeneration, - reportedOptions: { model: '' } - // Never admitted, so the empty report persists nothing. - }) return { - providerChildPhase: 'starting' as const, acquisitionGeneration, process: { hostId: 'local', diff --git a/src/main/native-chat/structured-chat-naming-command-first.test.ts b/src/main/native-chat/structured-chat-naming-command-first.test.ts deleted file mode 100644 index 730b66be5416..000000000000 --- a/src/main/native-chat/structured-chat-naming-command-first.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import type { StructuredAgentSessionAdapter } from './agent-session-wire/structured-agent-session-adapter' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider) => { - it.each(['Please repair login', ''])( - 'keeps the first real input eligible after compact: %j', - async (input) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const compact = vi.fn>(async () => ({ - state: 'accepted', - providerIdentity: null - })) - host.deps.adapter.compact = compact - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockResolvedValue({ state: 'admitted' }) - const readFirstPrompt = vi.fn(async (sessionId: string, startedAt: number) => - firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), startedAt) - ) - const generate = vi.fn(async () => 'Saved first input') - const naming = createStructuredChatNamingHandler({ - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - }) - const observer = vi.fn(naming) - host.deps.onSessionStatusChanged = observer - expect( - await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - ).toMatchObject({ ok: true }) - expect( - await host.conversationCommand( - { callerKey: 'client-1' }, - { - command: 'compact', - envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) - } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) - expect( - observer.mock.calls.some( - ([summary, options]) => - summary.status === 'working' && options.firstInputSubmissionKey === null - ) - ).toBe(true) - const events = acquire.mock.calls.at(-1)?.[0].events - const command = compact.mock.calls[0]?.[0].command - if (!events || !command) { - throw new Error('Missing actual compact turn') - } - for (let index = 0; index < 12; index++) { - events.publish() - } - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).not.toHaveBeenCalled() - events.appendLifecycleBatch?.( - 'compact-finished', - [ - { - kind: 'item', - identity: command.identity, - body: { - ...command.running, - state: 'completed', - outcome: 'success', - completedAt: Date.now() - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - const body = hostTestMessage(input) - expect( - await host.send( - { callerKey: 'client-1' }, - { body, envelope: envelope('agentSession.send', { body }) } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - if (input) { - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Saved first input') - ) - expect(generate).toHaveBeenCalledExactlyOnceWith(expect.anything(), input) - } else { - expect(generate).not.toHaveBeenCalled() - } - for (let index = 0; index < 12; index++) { - events.publish() - } - const later = hostTestMessage('Later input must not rename') - await host.send( - { callerKey: 'client-1' }, - { body: later, envelope: envelope('agentSession.send', { body: later }) } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).toHaveBeenCalledTimes(1) - expect(generate).toHaveBeenCalledTimes(input ? 1 : 0) - } - ) -}) diff --git a/src/main/native-chat/structured-chat-naming-status-hook.test.ts b/src/main/native-chat/structured-chat-naming-status-hook.test.ts deleted file mode 100644 index f00d207591dc..000000000000 --- a/src/main/native-chat/structured-chat-naming-status-hook.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { StructuredChatNamingDeps } from './structured-chat-naming' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s first-message naming status hook', (provider) => { - it.each([ - { outcome: 'accepted', completesDuringRead: false }, - { outcome: 'admitted', completesDuringRead: false }, - { outcome: 'accepted', completesDuringRead: true }, - { outcome: 'admitted', completesDuringRead: true } - ] as const)( - 'names a live $outcome send (first turn completes during journal read: $completesDuringRead)', - async ({ outcome, completesDuringRead }) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(HOST_TEST_SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockImplementation(async () => - outcome === 'admitted' - ? { state: 'admitted' } - : { - state: 'accepted', - providerIdentity: - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 } - } - ) - let finish: (value: string | null) => void = () => { - throw new Error('Not initialized') - } - const generation = new Promise((resolve) => { - finish = resolve - }) - const journalRead = Promise.withResolvers() - const readFirstPrompt = vi.fn(async (sessionId: string, hostStartedAt: number) => { - await journalRead.promise - return firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), hostStartedAt) - }) - const generate = vi.fn(() => generation) - const deps: StructuredChatNamingDeps = { - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - } - const naming = createStructuredChatNamingHandler(deps) - const status = vi.fn(naming) - host.deps.onSessionStatusChanged = status - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - expect(attached).toMatchObject({ ok: true }) - const body = hostTestMessage('Please repair the login flow') - const sent = await host.send( - { callerKey: 'client-1' }, - { - envelope: envelope('agentSession.send', { body }), - body - } - ) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - expect(generate).not.toHaveBeenCalled() - if (completesDuringRead) { - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - const events = acquire.mock.calls.at(-1)?.[0].events - const first = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.body.kind === 'message' && item.body.role === 'user' - ) - if (!events || !first) { - throw new Error('Missing acquired first turn') - } - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - body, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-completion' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 2 }, - { kind: 'turn', turnId: 'turn-1', userItemId: first.itemId, state: 'completed' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.publish() - await host.flushStreamedEvents(HOST_TEST_SESSION) - await vi.waitFor(() => - expect(status.mock.calls.at(-1)?.[0]).toMatchObject({ status: 'idle' }) - ) - expect(generate).not.toHaveBeenCalled() - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - } - journalRead.resolve() - await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(1)) - expect( - status.mock.calls.some( - ([summary, options]) => - summary.agent === provider && summary.status === 'working' && !options.replay - ) - ).toBe(true) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - finish('auth/login') - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('auth/login') - ) - } - ) -}) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts deleted file mode 100644 index c6e684dc4827..000000000000 --- a/src/main/pi/rpc-configured-default.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -// Pi's `--list-models` names no model it runs by default, so a new Pi chat with no saved pick showed -// "Model" and then whatever its session started on. Through the real Pi probe, adapter and catalog -// host, a chat started with no pick teaches the host the account's default for the next new chat. - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' -import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel -} from '../native-chat/agent-model-catalog/agent-project-model-override' -import { - closeProviderTimelineRigs, - openProviderTimelineRig -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' -import type { PiRpcConnection } from './rpc-session' -import { PiRpcSessionAdapter } from './rpc-session-adapter' - -const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } -// What `pi --list-models` prints: the first row is not the model Pi starts on. -const LISTING = [ - 'provider model context max-out thinking images', - 'anthropic claude-sonnet-4 200K 64K yes yes', - 'openai gpt-6 400K 128K yes yes' -].join('\n') -const AVAILABLE = [ - { provider: 'anthropic', id: 'claude-sonnet-4', name: 'Claude Sonnet 4', reasoning: true }, - // Its thinking map adds xhigh, which `--list-models` never shows. - { - provider: 'openai', - id: 'gpt-6', - name: 'GPT-6', - reasoning: true, - thinkingLevelMap: { xhigh: 'xhigh' } - } -] -const RUNS = AVAILABLE[1]! - -let root: string -const adapters: PiRpcSessionAdapter[] = [] -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-pi-configured-default-')) -}) -afterEach(async () => { - for (const adapter of adapters.splice(0)) { - await adapter.closeAll() - } - rmSync(root, { recursive: true, force: true }) - await closeProviderTimelineRigs() -}) - -function workspace(name: string, files: Record = {}): string { - const dir = join(root, name) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') - for (const [file, text] of Object.entries(files)) { - mkdirSync(join(dir, file, '..'), { recursive: true }) - writeFileSync(join(dir, file), text) - } - return dir -} - -type PiChild = { connection: PiRpcConnection; switchTo: (modelId: string) => void } - -/** A Pi child that starts on `startsOn` at `thinking`; a signed-out one lists no model. `switchTo` - * is a switch Orca didn't send, as an extension's `setModel` makes. */ -function piChild(available: readonly object[], thinking: string, startsOn: string): PiChild { - let model: object | null = AVAILABLE.find((entry) => entry.id === startsOn) ?? RUNS - const switchTo = (modelId: string): void => { - model = AVAILABLE.find((entry) => entry.id === modelId) ?? model - } - const connection: PiRpcConnection = { - pid: 4123, - closed: false, - rootVerdict: 'live', - processless: false, - lastCloseResult: null, - request: async (command: string, params?: Record) => { - if (command === 'get_state') { - return { - sessionFile: '/homes/pi/sessions/one.jsonl', - isStreaming: false, - isCompacting: false, - model, - thinkingLevel: thinking - } - } - if (command === 'get_available_models') { - return { models: available } - } - if (command === 'set_model') { - switchTo(String(params?.modelId)) - } - return command === 'get_commands' ? { commands: [] } : {} - }, - send: async () => {}, - close: async () => ({ root: 'exited', tree: 'exited' }), - pauseReading: () => {}, - resumeReading: () => {}, - onExit: () => {} - } - return { connection, switchTo } -} - -type ChatOptions = { - saved?: Record - resumed?: boolean - forked?: boolean - /** A new Pi session standing in for one whose saved file was gone. */ - replacement?: boolean - available?: object[] - thinking?: string - startsOn?: string - /** What happens between the chat's start and the pane's options read. */ - afterStart?: (chat: { - child: PiChild - adapter: PiRpcSessionAdapter - sessionId: string - }) => Promise -} - -/** A host with Pi's real probe and catalog service, and one Pi chat in `workspacePath`. */ -async function piHost() { - const records = new Map() - const clock = { now: 1_000 } - const store = new AgentModelCatalogStore({ now: () => clock.now }) - const catalog = createAgentModelCatalogService({ - store, - getRecord: (sessionId) => records.get(sessionId), - drivesRecord: () => true, - resolveAccountHome: async () => PI_HOME, - recordWorkspacePath: async (row) => row.launchDirectory ?? null, - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel, - probes: { - pi: createPiModelCatalogProbe({ - resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - resolveCommand: () => '/opt/pi/bin/pi', - probeVersion: async () => true, - homePath: '/home/user', - runListing: async () => LISTING - }) - } - }) - - let chats = 0 - /** A Pi chat in `workspacePath`: the host saves what its start listed, then the pane reads its - * options once, and the host saves each read's listing too. */ - async function chat(workspacePath: string, opts: ChatOptions = {}) { - const sessionId = `pi-${++chats}` - let child: PiChild | undefined - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. - records.set(sessionId, { - sessionId, - provider: 'pi', - accountHome: PI_HOME, - launchDirectory: workspacePath, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'ws-1', - workspaceKind: 'git-worktree' - } - } as AgentSessionRecord) - const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const adapter = new PiRpcSessionAdapter({ - resolveLaunch: async () => ({ - command: '/opt/pi/bin/pi', - cwd: workspacePath, - fullAccess: true, - previous: null, - ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}), - ...(opts.forked ? { forkFile: '/homes/pi/sessions/elsewhere.jsonl' } : {}), - ...(opts.replacement ? { replacement: 'unsaved' as const } : {}) - }), - readProcessStartTime: async () => 1, - openConnection: () => { - child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') - return child.connection - }, - onLifecycle: vi.fn(), - onSettled: vi.fn(), - onIdle: vi.fn(), - logger: { warn: vi.fn(), error: vi.fn() } - }) - adapters.push(adapter) - const acquired = await adapter.acquire({ - identity: { - sessionId, - workspaceId: 'ws-1', - hostId: 'local', - agent: 'pi', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-token', - options: opts.saved ?? {}, - events: rig.eventSink - }) - const started = await adapter - .open({ - sessionId, - fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - .catch(async (error: unknown) => { - await adapter.closeSession(sessionId) - throw error - }) - if (!started.catalogListing) { - throw new Error('a Pi start hands the host its listing') - } - catalog.recordLiveListing(sessionId, started.catalogListing) - if (opts.afterStart && child) { - await opts.afterStart({ child, adapter, sessionId }) - } - const read = async (): Promise => { - const { catalogListing } = await adapter.readOptions({ sessionId, fence: 1 }) - if (catalogListing) { - catalog.recordLiveListing(sessionId, catalogListing) - } - } - await read() - return { listing: started.catalogListing, read } - } - return { catalog, chat, store, clock } -} - -/** What a new Pi chat's composer paints first from a host answer. */ -function firstFrame(answer: AgentSessionModelCatalogResult) { - const seed = structuredAgentSessionSeedCatalog('pi') - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('pi', seed), - seed, - answer, - { newLaunch: true } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const select = (id: string) => { - const entry = snapshot.find((descriptor) => descriptor.id === id) - return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null - } - return { model: select('model'), effort: select('effort') } -} - -const NOTHING = { model: null, effort: null } - -describe('Pi’s default comes from what a chat with no pick runs', () => { - it('names no model from the listing, then the model and thinking level a no-pick chat ran', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - const listed = await catalog.read({ agent: 'pi', workspacePath: clean, waitForListing: true }) - expect(listed).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) - expect(firstFrame(listed)).toEqual(NOTHING) - - await chat(clean) - // The next new chat, in this workspace or another whose config picks nothing. - for (const path of [clean, workspace('other')]) { - const answer = await catalog.read({ agent: 'pi', workspacePath: path }) - expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) - expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') - expect(firstFrame(answer)).toEqual({ model: 'openai/gpt-6', effort: 'high' }) - } - }) - - it('keeps the placeholder in a workspace whose Pi settings pick the model', async () => { - const { catalog, chat } = await piHost() - await chat(workspace('clean')) - const configured = workspace('configured', { - '.pi/settings.json': '{"defaultProvider":"anthropic","defaultModel":"claude-sonnet-4"}' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: configured }))).toEqual( - NOTHING - ) - }) - - it('learns nothing from a chat whose workspace picks its model', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(workspace('configured', { '.pi/extensions/pick.ts': 'export default () => {}\n' })) - expect( - firstFrame(await catalog.read({ agent: 'pi', workspacePath: workspace('clean') })) - ).toEqual(NOTHING) - }) - - it('learns its configured default despite a saved next-message pick, but not from a resumed conversation', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect( - (await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing.configuredDefault - ).toEqual({ - modelId: 'openai/gpt-6', - effort: 'high' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - const resumed = await piHost() - await resumed.catalog.read({ agent: 'pi', waitForListing: true }) - expect( - (await resumed.chat(workspace('resumed'), { resumed: true })).listing - ).not.toHaveProperty('configuredDefault') - }) - - it('saves no default from a signed-out Pi, which lists no model', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - - // Nor does it forget one a signed-in chat taught. - await chat(clean) - await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the model the chat started on, not one an extension switched to later', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { afterStart: async ({ child }) => child.switchTo('claude-sonnet-4') }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the start model after a pick made in the chat, never the pick', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { - afterStart: async ({ adapter, sessionId }) => { - await adapter.setOption({ - sessionId, - fence: 1, - key: 'model', - value: 'anthropic/claude-sonnet-4' - }) - } - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('learns nothing from a chat forked from a conversation in another folder', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect((await chat(clean, { forked: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - }) - - it('keeps a learned thinking level only the chat’s menu offers when a later listing is newer', async () => { - const { catalog, chat, store, clock } = await piHost() - const clean = workspace('clean') - await chat(clean, { thinking: 'xhigh' }) - // A background re-list after the chat: `--list-models` offers no xhigh. - clock.now = 2_000 - store.recordSuccess( - agentModelCatalogFingerprint({ agent: 'pi', accountHome: PI_HOME, wslDistro: null }), - 'pi', - { - models: piModelCatalogFromListing(LISTING), - origin: 'probe' - }, - 'discovery' - ) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'xhigh' - }) - }) - - it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - const first = await chat(clean) - // Pi's own default changed since (a `/model` in a terminal): the next new chat starts on it. - await chat(clean, { startsOn: 'claude-sonnet-4' }) - // The first chat keeps running and reads its options again, as every turn does. - await first.read() - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'anthropic/claude-sonnet-4', - effort: 'high' - }) - }) - - it('learns the configured model and thinking level despite a saved next-message thinking pick', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { saved: { effort: 'low' } }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) - - it('learns from a new Pi session standing in for a conversation whose file was gone', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { replacement: true }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) -}) diff --git a/src/main/pi/rpc-session-adapter.test-fixture.ts b/src/main/pi/rpc-session-adapter.test-fixture.ts index 10810ec4abf0..b5d1d48dc400 100644 --- a/src/main/pi/rpc-session-adapter.test-fixture.ts +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -171,7 +171,7 @@ export async function setup( const started = await adapter.open({ sessionId, fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) lifecycle.mockClear() diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index ecd94e1f166b..912c0af766b5 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -94,8 +94,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } return { process, - acquisitionGeneration: session.generation, - providerChildPhase: 'starting' + acquisitionGeneration: session.generation } } catch (error) { if (!session) { diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts deleted file mode 100644 index 86e87486cca0..000000000000 --- a/src/main/runtime/agent-session-record-options.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { - openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore -} from './agent-session-record-store-test-harness' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { AcpStructuredOptions } from '../acp/acp-structured-options' -import { NewSessionResponseSchema } from '../acp/generated/acp-protocol.generated' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-options' -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-options-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -it('fails option hydration before ownership can be proved', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => { - throw new Error('model list unavailable') - } - }, - sessionId: SESSION, - fence: 2 - }) - ).rejects.toThrow('model list unavailable') -}) - -it('omits empty model values from other option producers', async () => { - await expect( - readNativeSessionOptions({ - adapter: { readOptions: async () => ({ models: [], current: { model: '', effort: 'off' } }) }, - sessionId: SESSION, - fence: 2, - priorOptions: { model: 'old-model', other: 'keep' } - }) - ).resolves.toEqual({ effort: 'off', other: 'keep' }) -}) - -it('durably replaces model-less ACP options when a later report reaches the option writer', async () => { - const response = NewSessionResponseSchema.parse( - JSON.parse( - readFileSync( - new URL('../acp/fixtures/omp-v17-windows-new-no-model.json', import.meta.url), - 'utf8' - ) - ) - ) - const reader = new AcpStructuredOptions() - reader.adoptSession(response) - const fixture = agentSessionRecordFixture() - const record = { - ...fixture, - provider: 'omp', - options: reader.reported(), - providerHandleChain: fixture.providerHandleChain.map((link) => ({ - ...link, - handle: { transport: 'acp', agent: 'omp', nativeId: response.sessionId } - })) - } - await seedTestAgentSessionRecordStore(directory, { records: [record] }) - const store = await openTestAgentSessionRecordStore(directory) - expect(store.getRecord(record.sessionId)?.options).toEqual({ effort: 'off' }) - - reader.adoptConfigOptions([ - ...(response.configOptions ?? []), - { - id: 'model', - name: 'Model', - category: 'model', - type: 'select', - currentValue: 'reported-model', - options: [{ value: 'reported-model', name: 'Reported Model' }] - } - ]) - const options = await readNativeSessionOptions({ - adapter: { readOptions: async () => reader.read() }, - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - priorOptions: store.getRecord(record.sessionId)?.options - }) - if (!options) { - throw new Error('The later ACP report must provide options') - } - await store.replaceSessionOptions({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - options, - now: NOW - }) - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(record.sessionId)?.options).toEqual({ - model: 'reported-model', - effort: 'off' - }) -}) - -it('persists resumed provider options atomically with owner proof', async () => { - const store = await openTestAgentSessionRecordStore(directory) - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - expectedFence: null, - spawnToken: 'spawn-options', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'new session' }, - operation: { - callerKey: 'client-1', - operationId: '1800000000000-00000000000000000000000000000000', - fingerprint: 'options-create' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1, - spawnToken: 'spawn-options' - }, - now: NOW - }) - const options = await readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ - models: [], - current: { model: 'gpt-tui', effort: 'low' } - }) - }, - sessionId: SESSION, - fence - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'codex-options-1', - handle: codexProviderHandle('thread-options'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW, - ...(options ? { options } : {}) - }) - - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) -}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 572234b3a23c..58c43b939bb0 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -214,20 +214,16 @@ export class AgentSessionRecordStore { link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number - options?: Readonly> }): Promise { - return this.mutate(args.sessionId, (record) => { - const proved = proveAgentSessionOwner({ + return this.mutate(args.sessionId, (record) => + proveAgentSessionOwner({ record, fence: args.fence, ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) - return args.options - ? replaceAgentSessionRecordOptions(proved, { ...args, options: args.options }) - : proved - }) + ) } /** Settle the failed attach and its reservation in one durable transaction. */ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 4a82cb904752..ba8221e0c9d1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -34,9 +34,13 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts deleted file mode 100644 index 25467e57248d..000000000000 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import '../unused-default-rpc-methods.test-fixture' -// `agentSession.hold` / `release` are kept answering for clients that still send them, and do -// nothing else: a view never starts or keeps an agent. -// -// Run against the REAL subscription registry rather than a stub, because the claim includes that a -// connection closing runs nothing hold-related — a stubbed registry could not show that. - -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 type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcResponse } from '../core' -import { RpcDispatcher } from '../dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' -import { getDefaultPersistedState } from '../../../../shared/constants' -import { RuntimeClientSettingsController } from '../../runtime-client-settings' - -const CONNECTION = 'connection-1' -const CLIENT = { - clientId: 'device-1', - clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - connectionId: CONNECTION -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let runtime: OrcaRuntimeService -let dispatcher: RpcDispatcher -let closeSession: Mock> -let acquire: Mock -let requests = 0 -let structuredNativeChatEnabled = true - -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - requests += 1 - await dispatcher.dispatchStreaming( - { id: `request-${requests}`, authToken: 'token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcResponse), - CLIENT - ) - return replies[0] as RpcResponse -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) - resetHostTestOperationIds() - requests = 0 - structuredNativeChatEnabled = true - closeSession = vi.fn(async () => true) - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - // A failed acquisition is proven gone, as the real adapters prove it; without this an - // acquire that throws leaves an unverifiable owner nothing may replace. - releaseAcquisition: vi.fn(async () => true), - closeSession, - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - setStructuredAgentSessionHost(host) - runtime = new OrcaRuntimeService() - // The structured surface is settings-gated for every caller, in-process included. - const clientSettings = new RuntimeClientSettingsController({ - getSettings: () => ({ - ...getDefaultPersistedState(root).settings, - experimentalNativeChat: structuredNativeChatEnabled - }), - updateSettings: () => undefined - }).get() - vi.spyOn(runtime, 'getClientSettings').mockImplementation(() => clientSettings) - dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ - ok: true - }) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('the hold surface, for clients that still call it', () => { - it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(registered).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a release without stopping anything, and a connection close runs nothing', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('answers a hold and a release whatever the host structured-chat setting says', async () => { - structuredNativeChatEnabled = false - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - expect(closeSession).not.toHaveBeenCalled() - }) - - it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION, 'evict') - acquire.mockRejectedValue(new Error('provider unavailable')) - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - }) -}) - -describe('a stream', () => { - it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const acquiresBefore = acquire.mock.calls.length - const frames: unknown[] = [] - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - (raw) => frames.push(JSON.parse(raw)), - CLIENT - ) - - expect(frames).toContainEqual( - expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) - ) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('keeps nothing alive when its transport dies', async () => { - const transport = new AbortController() - await dispatcher.dispatchStreaming( - { - id: 'desktop-subscription', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - { - signal: transport.signal, - clientId: 'desktop-renderer', - clientKind: 'runtime', - clientCapabilities: CLIENT.clientCapabilities - } - ) - - transport.abort() - await new Promise((resolve) => setTimeout(resolve, 20)) - - // The idle sweep, not a departing reader, is what stops an agent. - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) -}) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts index 8e7bdf86c223..2110c2e6b5bd 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts @@ -55,12 +55,16 @@ function providerAdapter(): StructuredAgentSessionAdapter { return { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async (input) => { if (acquireFails) { throw new Error('provider failed to start') } acquisitions++ return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4000 + acquisitions, diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 809b99aaeea3..065ff1819ffa 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -11,6 +11,9 @@ describe('ACP agents in the runtime registrations', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'omp']) + expect(structuredAgentRuntimeRegistration('codex')?.definition.capabilities.contextUsage).toBe( + true + ) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts deleted file mode 100644 index dbd98119d2e2..000000000000 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Failures the desktop host used to drop: it installed the runtime with no error callback, so -// each of these reached nobody. Every one now lands in the required logger under its own scope. - -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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { StructuredAgentSessionHostRuntimeState } from '../native-chat/agent-session-wire/structured-agent-session-host-runtime-state' -import { - StructuredAgentSessionDeliveryLoop, - type StructuredAgentSessionDeliveryLoopDeps -} from '../native-chat/agent-session-wire/structured-agent-session-delivery-loop' -import { - StructuredAgentSessionIdleSweep, - type StructuredAgentSessionIdleSweepDeps -} from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' -import { - StructuredAgentSessionQueuedMessageDrain, - type QueuedMessageDrainDeps -} from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' -import { - createStructuredAgentSessionLogger, - neverThrowingStructuredAgentSessionLogger -} from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { StructuredAgentSessionDeliveryOrder } from '../native-chat/agent-session-wire/structured-agent-session-delivery-order' -import { _resetTracerForTests, setActiveSink } from '../observability/tracer' -import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' -import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' -import { - STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED, - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' - -const SESSION = 'session-1' - -function rejectingHost(error: Error) { - return { - settleLateDispatch: vi.fn(async () => { - throw error - }), - releaseUnansweredDispatches: vi.fn(async () => { - throw error - }) - } -} - -describe('failures the desktop host used to drop', () => { - it('logs a late dispatch settlement that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('settlement write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.onDispatchSettledLate({ - sessionId: SESSION, - clientMessageId: 'client-1', - state: 'unknown', - reason: 'provider went idle' - }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - { - level: 'warn', - message: 'settling a dispatch the provider answered late failed', - fields: { scope: 'late-settlement', sessionId: SESSION, error } - } - ]) - ) - }) - - it('logs releasing the sends an idle provider never answered when that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('release write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.releaseUnansweredDispatches({ sessionId: SESSION }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - expect.objectContaining({ - fields: { scope: 'unanswered-dispatch', sessionId: SESSION, error } - }) - ]) - ) - }) - - it('logs a provider start or exit the host could not take', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('host refused the event') - const lifecycle = createStructuredAgentSessionLifecycleDelivery({ - handle: async () => { - throw error - }, - logger: log.logger, - drainObservedExits: async () => {} - }) - - lifecycle.deliver({ - type: 'started', - sessionId: SESSION, - fence: 1, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' } - }) - lifecycle.deliver({ - type: 'ended', - sessionId: SESSION, - reason: 'exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'generation-1' - }) - await lifecycle.drain() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'lifecycle-started', sessionId: SESSION, error }, - { scope: 'lifecycle-exit', sessionId: SESSION, error } - ]) - }) - - it('logs provider events the chat journal would not take', async () => { - const log = recordingStructuredAgentSessionLogger() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. - const deps = { logger: log.logger } as unknown as StructuredAgentSessionHostDeps - const runtime = new StructuredAgentSessionHostRuntimeState(deps, new Map()) - const sink = runtime.eventSinkFor(SESSION) - const error = new Error('journal append failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. - const journal = { - appendItem: async () => { - throw error - } - } as unknown as AgentSessionJournal - sink.bind({ journal, fence: 1, publish: () => {} }) - - sink.sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await expect(sink.drained()).resolves.toMatchObject({ ok: false }) - - expect(log.entries).toEqual([ - expect.objectContaining({ - level: 'error', - fields: { scope: 'journal-event-sink', sessionId: SESSION, error } - }) - ]) - }) -}) - -describe('host collaborators log with their own scope', () => { - const error = new Error('serialize refused') - const rejectingSerialize = async (): Promise => { - throw error - } - - it('the delivery loop logs a failed delivery, and a failed recording of it', async () => { - const log = recordingStructuredAgentSessionLogger() - const loop = new StructuredAgentSessionDeliveryLoop( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. - { - serialize: rejectingSerialize, - sessions: new Map(), - deliveryOrder: new StructuredAgentSessionDeliveryOrder(), - trackStart: (start: Promise) => start, - logger: log.logger - } as unknown as StructuredAgentSessionDeliveryLoopDeps - ) - - loop.wake(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'delivery-loop', sessionId: SESSION, error }, - { scope: 'delivery-loop-fail', sessionId: SESSION, error } - ]) - ) - }) - - it('the idle sweep logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const sweep = new StructuredAgentSessionIdleSweep( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a tick whose serialize rejects reads only these members. - { - sessions: new Map([[SESSION, {}]]), - isDisposed: () => false, - serialize: rejectingSerialize, - logger: log.logger - } as unknown as StructuredAgentSessionIdleSweepDeps - ) - - await sweep.tick() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'idle-sweep', sessionId: SESSION, error } - ]) - }) - - it('the queued-message drain logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const journal = { queuedMessages: { settlementOwed: () => true } } - const drain = new StructuredAgentSessionQueuedMessageDrain( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. - { - sessions: new Map([[SESSION, { journal }]]), - serialize: rejectingSerialize, - logger: log.logger - } as unknown as QueuedMessageDrainDeps - ) - - drain.schedule(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'queued-drain', sessionId: SESSION, error } - ]) - ) - }) -}) - -describe('a logger that throws', () => { - it('never throws out of the report, and still prints what failed', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const loggerError = new Error('logger broke') - const logger = neverThrowingStructuredAgentSessionLogger({ - warn: () => { - throw loggerError - }, - error: () => { - throw loggerError - } - }) - const error = new Error('what failed') - - expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() - expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() - expect(warn).toHaveBeenCalledWith('[agent-session] a step failed', { - scope: 'step', - error, - loggerError - }) - warn.mockRestore() - }) -}) - -describe('the production logger', () => { - afterEach(() => { - _resetTracerForTests() - vi.restoreAllMocks() - }) - - it('writes each entry to the trace file as a failed span named for its scope', () => { - const push = vi.fn() - setActiveSink({ push, flush: () => {}, close: () => {} }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { - scope: 'late-settlement', - sessionId: SESSION, - error: new Error('disk full') - }) - - expect(push).toHaveBeenCalledOnce() - expect(push.mock.calls[0]?.[0]).toMatchObject({ - type: 'effect-span', - name: 'agentSession.late-settlement', - attributes: { - level: 'warn', - message: 'settling a late dispatch failed', - sessionId: SESSION - }, - exit: { _tag: 'Failure', cause: expect.stringContaining('Error: disk full') } - }) - }) -}) - -describe('installing the runtime', () => { - let stateDirectory: string | null = null - - afterEach(async () => { - await stopStructuredAgentSessionRuntime() - if (stateDirectory) { - await rm(stateDirectory, { recursive: true, force: true }) - stateDirectory = null - } - }) - - it('refuses without a logger rather than dropping every failure', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - - await expect( - ensureStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately missing the logger, as a `@ts-nocheck` caller could drop it. - { - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }) - } as unknown as Parameters[0] - ) - ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) - }) - - it('hands the host a logger whose throw cannot reach the operation it reports', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }), - logger: { - warn: () => { - throw new Error('logger broke') - }, - error: () => { - throw new Error('logger broke') - } - } - }) - - expect(() => host.deps.logger.warn('a step failed', { scope: 'step' })).not.toThrow() - }) -}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 9f7c48f20f1c..46f931c7fa19 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -703,7 +703,7 @@ describe('cross-version structured agent sessions', () => { starts += 1 await startGate return { - providerChildPhase: 'starting' as const, + acquisitionGeneration: `cursor-${fence}`, process: { hostId: 'local', pid: 4242, 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 a7340834d773..a2341b1539d7 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 @@ -96,6 +96,7 @@ export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { { supportsCreate: () => true, acquire: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `stop-note-${fence}`, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index a24e11d902f3..9dcff82c307d 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -184,6 +184,7 @@ export function readyCodexProviderAdapter(input: { return startsWhenPublished( { acquire: async ({ fence }) => ({ + acquisitionGeneration: `wire-host-${fence}`, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index bc02eefb2cba..de0ce6d5d983 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -47,7 +47,7 @@ function openHost(): void { adapter: startsWhenPublished( { acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242,