From 3a0344158009ff2aec0fea4404a08967210df93d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:09:26 -0700 Subject: [PATCH] refactor(native-chat): structured agents declare their capabilities instead of shared code naming Claude and Codex (#25076) * refactor(native-chat): keep the provider resume handle opaque to shared code Shared structured-chat code parsed each provider's resume handle: Claude's session id and branch leaf, Codex's thread id, through a 'claude' | 'codex' union every new agent had to widen. The in-memory handle is now { transport, agent, nativeId, providerData? }: shared readers use nativeId, lease and handle-chain checks compare transport and agent, and only the Claude adapter reads its leaf (providerData). Stored and wire forms are unchanged for Claude and Codex. One encoding module writes their typed shapes and decodes both those and the neutral shape a new transport uses, which an older build refuses as unreadable rather than reading as Codex. Key and root strings, which fork seeds, superseded creations and resume offers persist, stay byte-identical. The journal's own handle type becomes the journal-row and attach-wire encoding of the same handle, and the journal identity carries the neutral handle (null before the provider proves one). No user-visible change. * fix(native-chat): derive journal-row provider handles from the journal identity The journal row converter now takes the identity every caller already holds, so a row's handle has one obvious constructor. Tests that wrote the in-memory handle straight into journal rows now build it through that converter, and the processless Claude fixture names a not-yet-proved handle as null. * fix(native-chat): refuse a stored provider handle written in both forms A typed Claude or Codex handle that also carries the neutral form's transport, agent, native id or provider data named two identities; it was read as Claude or Codex and the next write dropped the other one. Such a row now stays unreadable and is set aside untouched. * refactor(native-chat): route structured agents through registered definitions The structured-chat shared layer named Claude and Codex in a closed router type, in option reads, in frame classification, and in renderer label and validation branches, so adding an agent meant editing shared types. Each agent now has a definition (capabilities, resting option rules) owned by its own module; the router is a registry of definition + adapter pairs built at runtime setup, and shared code reads the definition instead of the name. No behavior change for Claude or Codex; no wire or stored shape change. * refactor(native-chat): name the structured agent list once in host types The host types, conversation commands, provider-session ownership and the runtime's create path spelled the structured agent list inline; they now use the one shared type the record and wire already validate against. * fix(native-chat): narrow the record before reading its agent's option rules * refactor(native-chat): make the router's registrations the only agent definition lookup The at-rest option reads looked definitions up in a second, separately populated map, so a registered definition could route and declare capabilities while the static map decided which options a chat at rest accepted. The router now answers `definition(agent)` from its registrations, the at-rest readers take it through the host's adapter, and the built-in definitions are listed only where the runtime composes the router. * test(native-chat): use opaque handle in queued rejection fixture * test(native-chat): share one Codex journal identity in the integration suite Main grew the suite to the 800-line limit; the opaque-handle import pushed it over. The two tests built the same identity inline. * refactor(agent-session): name the handle's adapter state resumeCursor Rename the neutral provider handle's providerData to resumeCursor before any row persists the neutral form: it is an adapter-owned resume position (Claude's transcript leaf), never identity. Claude/Codex stored and wire bytes are unchanged; their typed shapes never carried the field. State the stored-form contract (a handle's field set is closed; later per-link data goes on the chain link, which every build preserves) and pin it with a record round-trip test. Document that transport records the id space the native id was minted in, which can differ from the agent's current transport. * refactor(agent-session): one required agent registry; declarations admit what they claim A StructuredAgentRegistry, built once from the {definition, adapter} registrations, is now a required host dependency and the router routes with it. The adapter interface loses its optional router-only capabilities?() and definition?(); every reader (options at rest, thread goal, rewind, the /compact handover) asks the registry. A live session still narrows rewind through the adapter, and the host combines declared and narrowed in one helper. The registry refuses a registration that declares compact, a thread goal or rewind without the adapter method behind it. /compact is admitted by the declared capability, so an agent that declares compact:false gets the commandRefused fact instead of a thrown error. Also: the cut-turn notice names the agent from the catalog, create-support builds the account home through agentSessionAccountHome, and the turn status text older clients read names the session's agent instead of defaulting to Codex (Claude/Codex text unchanged). * refactor(agent-session): the router applies the declared rewind itself The router holds the registry, so its rewindSupport answers the owner's declared rewind narrowed by the adapter, in one place no reader can bypass. The host-side combining helper is gone; readers ask the adapter they hold. * chore(agent-session): the registry is the one lookup; drop the test-only empty capability record * test(native-chat): build the Stop-opened-turn test's identity and turn context the current way The test (#25056) landed before the opaque provider handle (#24991), so main still built the old {kind, threadId} handle; the turn context also needs this branch's agent registry. * test(ratchet): require src/main/provider-process now that it has landed * test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test Main's #25706 and this branch both added the import at different lines; the merge kept both. --- .../claude-api-retry-idle-sweep.test.ts | 2 + ...claude-context-usage-unloaded-turn.test.ts | 23 +- .../claude-structured-agent-definition.ts | 24 ++ ...-structured-effort-default-at-rest.test.ts | 14 +- .../claude-structured-launch-resolution.ts | 6 +- .../claude-structured-session-adapter.ts | 9 - .../claude-structured-session-test-support.ts | 10 + .../codex-provider-retry-idle-sweep.test.ts | 2 + .../codex-structured-agent-definition.ts | 25 +++ .../codex-structured-launch-resolution.ts | 6 +- .../codex-structured-question-order.test.ts | 2 + .../codex/codex-structured-session-adapter.ts | 3 - .../codex-structured-session-close.test.ts | 4 +- .../agent-model-catalog-fingerprint.ts | 6 +- .../agent-model-catalog-service.ts | 12 +- .../provider-frame-disposition.ts | 63 +++--- .../structured-agent-definition.ts | 23 ++ .../structured-agent-registry.ts | 72 ++++++ ...-agent-session-accept-then-deliver.test.ts | 2 + ...nt-session-adapter-router-registry.test.ts | 211 ++++++++++++++++++ ...ent-session-adapter-router-test-support.ts | 64 ++++++ ...tured-agent-session-adapter-router.test.ts | 28 +-- ...structured-agent-session-adapter-router.ts | 88 +++++--- .../structured-agent-session-adapter.ts | 7 +- ...tured-agent-session-adopted-import.test.ts | 2 + ...ured-agent-session-append-delivery.test.ts | 2 + ...red-agent-session-at-rest-commands.test.ts | 2 + ...-agent-session-claude-compact-stop.test.ts | 2 + ...-agent-session-claude-echo-working.test.ts | 2 + ...-agent-session-claude-option-queue.test.ts | 2 + ...d-agent-session-claude-queued-stop.test.ts | 2 + ...red-agent-session-claude-root-exit.test.ts | 2 + ...t-session-claude-stop-ends-session.test.ts | 12 +- ...ssion-claude-stop-exit-ends-record.test.ts | 2 + ...agent-session-claude-stop-turn-end.test.ts | 2 + ...ctured-agent-session-close-verdict.test.ts | 2 + ...tured-agent-session-codex-stop-row.test.ts | 2 + .../structured-agent-session-command-turn.ts | 8 +- ...ed-agent-session-conversation-stop.test.ts | 2 + ...ured-agent-session-crash-mid-start.test.ts | 2 + ...tured-agent-session-crash-turn-end.test.ts | 2 + .../structured-agent-session-delivery-loop.ts | 3 + ...ession-failed-create-owner-verdict.test.ts | 2 + ...session-failed-create-sink-release.test.ts | 2 + ...tured-agent-session-grouped-prompt.test.ts | 2 + .../structured-agent-session-host-delivery.ts | 1 + ...structured-agent-session-host-mutations.ts | 2 +- ...ed-agent-session-host-test-abandon.test.ts | 2 + ...uctured-agent-session-host-test-harness.ts | 2 + .../structured-agent-session-host-types.ts | 6 +- .../structured-agent-session-host.test.ts | 3 + ...ured-agent-session-import-mismatch.test.ts | 2 + ...ured-agent-session-late-settlement.test.ts | 2 + ...gent-session-legacy-handoff-record.test.ts | 2 + ...ssion-main-agent-working-agreement.test.ts | 2 + ...ctured-agent-session-mutation-admission.ts | 36 +-- ...ructured-agent-session-mutation-context.ts | 1 + ...agent-session-mutation-owed-import.test.ts | 6 +- ...red-agent-session-mutation-turn-context.ts | 38 ++++ ...d-agent-session-newer-content-host.test.ts | 2 + ...agent-session-operation-settlement.test.ts | 3 + ...nt-session-operation-start-refusal.test.ts | 10 +- ...ed-agent-session-option-settlement.test.ts | 2 + ...uctured-agent-session-options-read.test.ts | 8 +- .../structured-agent-session-options-read.ts | 55 +++-- ...ed-agent-session-owed-work-release.test.ts | 2 + ...ctured-agent-session-prompt-cancel.test.ts | 3 + ...gent-session-provider-child-record.test.ts | 2 + ...red-agent-session-provider-restore.test.ts | 2 + ...red-agent-session-provider-started.test.ts | 2 + ...ructured-agent-session-provider-support.ts | 10 +- ...session-queued-message-rig.test-fixture.ts | 8 +- ...tured-agent-session-recovery-exits.test.ts | 2 + ...ctured-agent-session-refusal-retry.test.ts | 2 + ...gent-session-registered-definition.test.ts | 160 +++++++++++++ ...uctured-agent-session-repeat-press.test.ts | 46 ++-- ...ctured-agent-session-repeated-stop.test.ts | 2 + .../structured-agent-session-rest-test-rig.ts | 2 + ...ssion-restart-interruption-test-harness.ts | 2 + ...session-restart-status-publication.test.ts | 2 + ...tured-agent-session-rewind-at-rest.test.ts | 2 + .../structured-agent-session-rewind.test.ts | 2 + .../structured-agent-session-rewind.ts | 3 +- ...red-agent-session-send-idempotency.test.ts | 5 + ...agent-session-send-open-stale-turn.test.ts | 2 + ...red-agent-session-send-preparation.test.ts | 2 + ...session-send-restarts-failed-start.test.ts | 2 + ...agent-session-settled-attach-retry.test.ts | 7 + ...agent-session-start-failure-writer.test.ts | 2 + ...-session-startup-reconcile-failure.test.ts | 2 + ...gent-session-stop-note-opened-turn.test.ts | 3 + ...t-session-stop-note-withdrawn-send.test.ts | 3 + ...red-agent-session-surface-lifetime.test.ts | 2 + ...ructured-agent-session-thread-goal.test.ts | 44 ++-- .../structured-agent-session-thread-goal.ts | 2 +- .../structured-agent-session-turns.test.ts | 15 ++ .../structured-agent-session-turns.ts | 5 + ...uctured-agent-session-unopened-tab.test.ts | 2 + ...sion-view-start-after-failed-start.test.ts | 2 + ...t-session-wedged-profile-migration.test.ts | 2 + .../structured-conversation-command.test.ts | 2 + .../structured-conversation-command.ts | 4 +- ...structured-conversation-compaction.test.ts | 33 ++- .../structured-provider-session-ownership.ts | 3 +- ...structured-agent-session-create-support.ts | 41 ++-- ...ured-agent-session-adoption-replay.test.ts | 2 + .../structured-agent-session-at-rest.test.ts | 5 +- .../structured-agent-session-hold.test.ts | 2 + .../methods/structured-chat-tab-table.test.ts | 2 + .../structured-agent-session-runtime.ts | 10 +- .../structured-claude-pending-rewind.test.ts | 15 +- .../NativeChatStructuredSession.tsx | 2 +- .../use-host-model-catalog-upgrade.ts | 3 +- .../use-structured-agent-session-options.ts | 5 +- ...red-agent-session.cut-turn-notice.test.tsx | 15 ++ .../use-structured-agent-session.ts | 5 +- .../structured-agent-session-launch-label.ts | 4 +- ...ctured-agent-session-launch-persistence.ts | 7 +- ...tructured-agent-session-provisional-tab.ts | 9 +- src/shared/agent-session-account-home.test.ts | 37 +++ src/shared/agent-session-account-home.ts | 45 ++++ src/shared/agent-session-capabilities.ts | 29 +++ ...ent-session-option-catalog-claude-codex.ts | 1 + .../agent-session-option-catalog-types.ts | 3 + src/shared/agent-session-record.ts | 16 +- src/shared/agent-session-turn-record.test.ts | 16 ++ src/shared/agent-session-turn-record.ts | 9 +- src/shared/agent-turn-lifecycle-text.ts | 2 +- .../structured-agent-session-mutation.ts | 12 +- src/shared/tui-agent-display-names.ts | 5 + ...ss-version-agent-session-wire.unit.test.ts | 3 + ...-chat-owner-status-activation.unit.test.ts | 2 + 132 files changed, 1340 insertions(+), 315 deletions(-) create mode 100644 src/main/claude/claude-structured-agent-definition.ts create mode 100644 src/main/codex/codex-structured-agent-definition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-definition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-registry.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts create mode 100644 src/shared/agent-session-account-home.test.ts create mode 100644 src/shared/agent-session-account-home.ts create mode 100644 src/shared/agent-session-capabilities.ts 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 695acefbb6f..c5a05cfb556 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -20,6 +20,7 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans 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 SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -79,6 +80,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 5473719ce11..c1ce921caca 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -16,7 +16,6 @@ import { import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' @@ -29,6 +28,11 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { + claudeAndCodexAgents, + NO_STRUCTURED_AGENTS +} from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -81,10 +85,7 @@ function runTurn( }) } -function readOptions( - journal: AgentSessionJournal, - adapter: Partial -) { +function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegistry) { const running = { journal, child: { fence: 1, generation: 'generation-1' }, @@ -94,9 +95,9 @@ function readOptions( const context = { deps: { adapter: { - readOptions: async () => ({ models: [], current: { model: 'opus' } }), - ...adapter + readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, + agents, store: { getRecord: () => undefined } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -171,7 +172,7 @@ describe('context usage for a turn row outside the loaded page', () => { const state = attachTail(journal, 3) expect(state.hasOlder).toBe(true) expect(hasTurnRow(state)).toBe(false) - const options = await readOptions(journal, { recordsContextUsage: () => true }) + const options = await readOptions(journal, claudeAndCodexAgents()) expect(options.contextUsage?.current).toEqual( latestStructuredAgentContextFacts(journal.snapshot().items) @@ -190,7 +191,7 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-a', 1_000, [120_000, 130_000, 140_000]) await settle() - const options = await readOptions(journal, {}) + const options = await readOptions(journal, NO_STRUCTURED_AGENTS) expect(options).not.toHaveProperty('contextUsage') // An older host answers the same way, so the ring reads only the loaded page, as before. expect(selectStructuredAgentContextUsage(attachTail(journal, 2).items)).toBeNull() @@ -213,7 +214,7 @@ describe('context usage for a turn row outside the loaded page', () => { expect(hasTurnRow(live)).toBe(false) expect(live.unloadedTurnRevisions).toBe(1) - const options = await readOptions(journal, { recordsContextUsage: () => true }) + const options = await readOptions(journal, claudeAndCodexAgents()) expect( selectStructuredAgentContextUsage(live.items, options.contextUsage?.current) ).toMatchObject({ usedTokens: 250_000, windowTokens: 1_000_000 }) @@ -227,7 +228,7 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-b', 3_000, []) await settle() const hostAnswer = async () => - (await readOptions(journal, { recordsContextUsage: () => true })).contextUsage?.current + (await readOptions(journal, claudeAndCodexAgents())).contextUsage?.current // The client reads the host once per turn, and again whenever its window loses a turn row. let state = attachTail(journal, 200) let host = await hostAnswer() diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts new file mode 100644 index 00000000000..ca37f7abfad --- /dev/null +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -0,0 +1,24 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { isClaudeStructuredOptionKey } from './claude-structured-options' +import { claudeFallbackModelOptions } from './claude-structured-session-options' + +export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { + agent: 'claude', + capabilities: { + // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. + rewind: false, + compact: true, + threadGoal: false, + // A session at rest still reports the usage its journal recorded. + contextUsage: true, + imagePrompts: true, + steering: 'inject', + // The permission mode is a launch flag the CLI enforces. + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: isClaudeStructuredOptionKey, + fallbackModels: claudeFallbackModelOptions, + effortDefaultsToModel: true + } +} 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 0228b117168..edb7c2d0919 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 @@ -14,6 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' 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 { @@ -145,10 +146,19 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { getRecord: () => record, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) }) - const resting = { child: null, params: { provider: 'claude' } } + const resting = { + child: null, + params: { provider: 'claude' }, + journal: { threadGoal: () => null, contextUsage: () => null } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: {}, + agents: claudeAndCodexAgents(), + store: { getRecord: () => record }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 979b5ae5963..95e43e2a276 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -34,6 +34,7 @@ import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -250,8 +251,9 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } - if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { - throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + const pinned = agentConfigDirectoryVariable('claude') + if (record.accountHome.variable !== pinned) { + throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) } // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 0e3176a1813..ade13231d31 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -81,12 +81,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda supportsLocation = supportsClaudeStructuredLocation - // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. - rewindSupport: NonNullable = () => ({ - supported: false, - reason: 'unsupported' - }) - acquire = (input: StructuredAgentSessionAcquireInput): Promise => { this.settledExitErrors.delete(input.identity.sessionId) return acquireClaudeSession({ @@ -267,9 +261,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - // Provider-level: a session at rest still reports the usage its journal recorded. - recordsContextUsage = (): boolean => true - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) ] diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 02cf46aadc8..2b5d28c99bc 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -325,3 +325,13 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { export function tick(): Promise { return new Promise((resolve) => setImmediate(resolve)) } + +/** Delivers one frame from Claude on `connection`, under the provider session it runs. */ +export function claudeFrame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +/** Whether anything sent to Claude on `connection` carries `text`. */ +export function claudeWasSent(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} 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 722858828f2..d0263b0c100 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -20,6 +20,7 @@ import { createCodexJournalTranslator } from './codex-structured-journal-transla 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 SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -77,6 +78,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts new file mode 100644 index 00000000000..8aab5137b11 --- /dev/null +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -0,0 +1,25 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { isCodexTurnOptionKey } from './codex-structured-turn-start' + +export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { + agent: 'codex', + capabilities: { + // A thread with legacy, unpaginated history narrows this to unsupported once it runs. + rewind: true, + compact: true, + // A goal change at rest starts the agent first. + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + // Approval and sandbox policy ride on the thread; the app-server enforces them. + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: isCodexTurnOptionKey, + // No built-in list: the client fills the current model from its own unknown-model defaults. + fallbackModels: () => null, + // A running child answers only the effort its thread reported, never the model's default. + effortDefaultsToModel: false + } +} diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 19945c9a6c8..b48cc4cae11 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,6 +15,7 @@ import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -85,8 +86,9 @@ export function createCodexStructuredLaunchResolver( ) { throw new Error('codex structured sessions require Windows process creation-time proof') } - if (accountHome.variable !== 'CODEX_HOME') { - throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) + const pinned = agentConfigDirectoryVariable('codex') + if (accountHome.variable !== pinned) { + throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`) } const { command, environment } = await resolveCodexStructuredInvocation(deps) // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index c63d78439ab..f942b5f83a8 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -38,6 +38,7 @@ import { 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' } @@ -95,6 +96,7 @@ beforeEach(async () => { } store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 1c5a034fd63..11d9a2ffca3 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -259,9 +259,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.requestTimeoutMs ) - // Provider-level: a goal change at rest starts the agent first. - supportsThreadGoal = (): boolean => true - answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerCodexStructuredPrompt({ request, sessions: this.sessions }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 4f9f9a2ab0d..6d0d9ad7419 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -16,7 +16,7 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' @@ -211,7 +211,7 @@ describe('Codex structured session close lifecycle', () => { it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => { const { adapter, connections, events } = adapterFixture() - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude: claudeAdapterStub(), codex: adapter }, async () => {} ) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index 28b1f8729d3..70c59acd77b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,6 +4,8 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' +import { agentConfigDirectoryVariable } from '../../../shared/agent-session-account-home' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' /** * Everything that changes which models a listing can answer with: the agent, @@ -55,7 +57,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: 'claude' | 'codex', + agent: AgentSessionHandleProvider, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { @@ -65,7 +67,7 @@ export function agentModelCatalogSessionAccess( store, fingerprint: agentModelCatalogFingerprint({ agent, - accountHomeVariable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + accountHomeVariable: agentConfigDirectoryVariable(agent), accountHomePath, wslDistro: null }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 638b0f54272..a240cfa3e6d 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,5 +1,9 @@ import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -16,9 +20,7 @@ export type AgentModelCatalogServiceDeps = { /** The account home a structured launch for this agent would pin right now — * the SAME resolver the create path fills `record.accountHome` with, so a * record-less read can never answer from another account's listing. */ - resolveAccountHome: ( - agent: 'claude' | 'codex' - ) => Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> + resolveAccountHome: (agent: AgentSessionHandleProvider) => Promise /** Session-less listers, one per agent that has one on this host. */ probes?: Partial> /** Whether the workspace's own config could pick a model other than the listed default. */ @@ -99,7 +101,7 @@ export function createAgentModelCatalogService( // Probes spawn natively; a WSL-pinned record has no host-side lister. accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null } else { - let resolved: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string } + let resolved: AgentSessionAccountHome try { resolved = await deps.resolveAccountHome(params.agent) } catch { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 1b89afd3997..56c5244e2ff 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -279,24 +279,44 @@ export function isDeltaProviderFrameKind(kind: string): boolean { return notificationKind(kind).toLowerCase().endsWith('delta') } -function catalogClassification( - provider: string, - kind: string -): ProviderFrameClassification | undefined { - if (provider === 'codex') { - const item = itemKind(kind) - if (item !== null) { - return CODEX_ITEM_CLASSIFICATIONS[item] +const CODEX_NOTIFICATION_CLASSIFICATIONS: Readonly> = + PROVIDER_FRAME_CLASSIFICATIONS.codex +const CLAUDE_FRAME_CLASSIFICATIONS: Readonly> = + PROVIDER_FRAME_CLASSIFICATIONS.claude + +/** Each provider's own table, by provider id: an unlisted provider has none, so every frame it + * sends that no rule above claims stays a visible row. */ +const PROVIDER_FRAME_CATALOGS: ReadonlyMap< + string, + (kind: string, payload: unknown) => ProviderFrameClassification | undefined +> = new Map< + keyof ProviderFrameClassificationTable, + (kind: string, payload: unknown) => ProviderFrameClassification | undefined +>([ + [ + 'codex', + (kind) => { + const item = itemKind(kind) + if (item !== null) { + return CODEX_ITEM_CLASSIFICATIONS[item] + } + return CODEX_NOTIFICATION_CLASSIFICATIONS[notificationKind(kind)] } - return PROVIDER_FRAME_CLASSIFICATIONS.codex[ - notificationKind(kind) as CodexAppServerNotificationMethod - ] - } - if (provider === 'claude') { - return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] - } - return undefined -} + ], + [ + 'claude', + (kind, payload) => { + if (kind === 'message:result') { + const subtype = + typeof payload === 'object' && payload !== null && 'subtype' in payload + ? payload.subtype + : undefined + return subtype === 'success' ? 'status-chrome' : 'error-surface' + } + return CLAUDE_FRAME_CLASSIFICATIONS[kind] + } + ] +]) export function classifyProviderFrame( provider: string, @@ -312,12 +332,5 @@ export function classifyProviderFrame( if (isDeltaProviderFrameKind(kind)) { return 'stream-into-item' } - if (provider === 'claude' && kind === 'message:result') { - const subtype = - typeof payload === 'object' && payload !== null - ? (payload as Record).subtype - : undefined - return subtype === 'success' ? 'status-chrome' : 'error-surface' - } - return catalogClassification(provider, kind) ?? 'timeline-substantive' + return PROVIDER_FRAME_CATALOGS.get(provider)?.(kind, payload) ?? 'timeline-substantive' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts new file mode 100644 index 00000000000..8744ccfde1f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -0,0 +1,23 @@ +// What the host knows about a structured agent before any session of it runs. +// +// Each agent's own module declares its definition, and runtime composition registers it with the +// agent's adapter in one StructuredAgentRegistry. That registry is the only lookup: the router and +// shared host code read a definition through it and never branch on the agent's name. + +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' + +export type StructuredAgentDefinition = { + /** The Orca agent whose sessions this definition describes. */ + agent: string + capabilities: AgentSessionCapabilities + /** How a session's options read and change while no child runs. */ + restingOptions: { + /** Whether the agent takes a pick of this option key. */ + acceptsKey: (key: string) => boolean + /** The models a running child falls back to with no catalog; null when it has none. */ + fallbackModels: () => AgentSessionModelOption[] | null + /** An unpicked effort reads as the model's default effort, as a running child reports it. */ + effortDefaultsToModel: boolean + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts new file mode 100644 index 00000000000..c6f305548ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts @@ -0,0 +1,72 @@ +// The structured agents this runtime drives, built once from their registrations. +// +// The one place a definition is looked up: the router routes with it and every host reader asks it +// what an agent declares. A declaration the agent's adapter could not honour is refused here, so a +// declared capability always has the adapter method behind it. + +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ +export type StructuredAgentRegistration = { + definition: StructuredAgentDefinition + adapter: StructuredAgentSessionAdapter +} + +type AdapterMethod = keyof StructuredAgentSessionAdapter + +/** The adapter methods each declared capability needs. */ +function requiredAdapterMethods(capabilities: AgentSessionCapabilities): AdapterMethod[] { + return [ + ...(capabilities.compact ? (['compact'] as const) : []), + ...(capabilities.threadGoal ? (['changeThreadGoal'] as const) : []), + ...(capabilities.rewind ? (['rewind', 'recoverRewind'] as const) : []) + ] +} + +export class StructuredAgentRegistry { + private readonly byAgent: ReadonlyMap + + constructor(registrations: readonly StructuredAgentRegistration[]) { + const byAgent = new Map() + for (const registration of registrations) { + const { agent, capabilities } = registration.definition + if (byAgent.has(agent)) { + throw new Error(`structured agent ${agent} is registered twice`) + } + const missing = requiredAdapterMethods(capabilities).find( + (method) => !registration.adapter[method] + ) + if (missing) { + throw new Error( + `structured agent ${agent} declares a capability its adapter has no ${missing} for` + ) + } + byAgent.set(agent, registration) + } + this.byAgent = byAgent + } + + /** The registration for `agent`; null for an agent this runtime does not drive. */ + registration(agent: string): StructuredAgentRegistration | null { + return this.byAgent.get(agent) ?? null + } + + registrations(): readonly StructuredAgentRegistration[] { + return [...this.byAgent.values()] + } + + definition(agent: string): StructuredAgentDefinition | null { + return this.byAgent.get(agent)?.definition ?? null + } + + definitions(): readonly StructuredAgentDefinition[] { + return this.registrations().map((registration) => registration.definition) + } + + /** What `agent` declares; a live session may narrow it (see `rewindSupport`), never widen it. */ + capabilities(agent: string): AgentSessionCapabilities | null { + return this.definition(agent)?.capabilities ?? null + } +} 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 3f1b4046f54..99900a2a893 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 @@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- 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' } @@ -78,6 +79,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa async function startHost(): Promise { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 new file mode 100644 index 00000000000..c16c035ef6d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -0,0 +1,211 @@ +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', + 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: false, + 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-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts new file mode 100644 index 00000000000..4d1dffb8a39 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -0,0 +1,64 @@ +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import { StructuredAgentRegistry } from './structured-agent-registry' + +/** A runtime that drives no agent: what a host over a bare adapter double declares. */ +export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([]) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the optional methods a declaration needs; this double has none, so its agents declare none. +const NO_METHODS = {} as StructuredAgentSessionAdapter + +/** This build's two agents over doubles. Each declares only what its double implements, so a + * double need not carry every method its real adapter has. */ +export function claudeAndCodexAgents( + adapters: + | { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter } + | StructuredAgentSessionAdapter = NO_METHODS +): StructuredAgentRegistry { + const { claude, codex } = 'claude' in adapters ? adapters : { claude: adapters, codex: adapters } + return new StructuredAgentRegistry([ + { definition: implementedBy(CLAUDE_STRUCTURED_AGENT, claude), adapter: claude }, + { definition: implementedBy(CODEX_STRUCTURED_AGENT, codex), adapter: codex } + ]) +} + +/** This build's two agents exactly as declared, for a host whose double gains its methods per test. */ +export function claudeAndCodexDeclared(): StructuredAgentRegistry { + const unused = (): never => { + throw new Error('a registry never calls its adapters') + } + return claudeAndCodexAgents({ + ...NO_METHODS, + compact: unused, + changeThreadGoal: unused, + rewind: unused, + recoverRewind: unused + }) +} + +/** A router over this build's two agents. */ +export function claudeAndCodexRouter( + adapters: { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter }, + closeAdapters: () => Promise +): StructuredAgentSessionAdapterRouter { + return new StructuredAgentSessionAdapterRouter(claudeAndCodexAgents(adapters), closeAdapters) +} + +function implementedBy( + definition: StructuredAgentDefinition, + adapter: StructuredAgentSessionAdapter +): StructuredAgentDefinition { + const declared = definition.capabilities + return { + ...definition, + capabilities: { + ...declared, + compact: declared.compact && Boolean(adapter.compact), + threadGoal: declared.threadGoal && Boolean(adapter.changeThreadGoal), + rewind: declared.rewind && Boolean(adapter.rewind && adapter.recoverRewind) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 076e2de7540..7f8ad5c9106 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -7,7 +7,7 @@ import type { AgentSessionAcquisition, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { @@ -51,7 +51,7 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { const failure = new Error('root exited') const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) @@ -71,7 +71,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { claude.closeSession = closeSession claude.dispatch = dispatch const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) @@ -96,7 +96,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { }) const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => {} ) @@ -129,7 +129,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) claude.dispatch = dispatch const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) const stopSession = router[method] @@ -156,7 +156,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, @@ -183,7 +183,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.stopEndsSession = () => true const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.stopEndsSession('session-1')).toBe(false) await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) @@ -194,7 +194,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.awaitStoppedRequestEnd = vi.fn(async () => undefined) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) await router.awaitStoppedRequestEnd('session-1', 5) expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled() @@ -207,7 +207,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.routePromptCancel = () => ({ kind: 'stop' }) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined() await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) @@ -222,7 +222,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) const claude = adapterOf(vi.fn(async () => true)) claude.acquire = acquire - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) @@ -241,7 +241,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('keeps a per-session stop proof and reports no stop for a session it never routed', async () => { const claude = adapterOf(vi.fn(async () => true)) const closeAdapters = vi.fn(async () => undefined) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, closeAdapters ) @@ -266,7 +266,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('asks the adapters to release an unrouted session rather than answering from the close proof', async () => { const claudeRelease = vi.fn(async () => true) const codexRelease = vi.fn(async () => false) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude: adapterOf(claudeRelease), codex: adapterOf(codexRelease) }, async () => undefined ) @@ -284,7 +284,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const closeSession = vi.fn(async () => true) claude.dispatch = dispatch claude.closeSession = closeSession - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, vi.fn(async () => { throw failure @@ -322,7 +322,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { resolveAcquire = resolve }) ) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) 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 5edc3793d02..e7d7ba2cf4c 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 @@ -6,41 +6,49 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { + StructuredAgentRegistration, + StructuredAgentRegistry +} from './structured-agent-registry' -type RoutedAgent = 'claude' | 'codex' -type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } +type SessionRoute = { + registration: StructuredAgentRegistration + state: 'live' | 'stopped' +} +/** Routes each session to the adapter its agent is registered with. Adding an agent is one more + * registration; nothing here names one. */ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { private readonly routes = new Map() private allAdaptersClosed = false private closePromise: Promise | null = null constructor( - private readonly adapters: Record, + private readonly agents: StructuredAgentRegistry, private readonly closeAdapters: () => Promise ) {} supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { - const adapter = this.adapterForAgent(agent) + const adapter = this.agents.registration(agent)?.adapter return adapter ? (adapter.supportsLocation?.(location) ?? false) : false } supportsLocation = (location: AgentSessionExecutionLocation): boolean => - Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + this.adapters().some((adapter) => adapter.supportsLocation?.(location) ?? false) - /** Both adapters already gate their own shutdown, so the router only has to stop UNDOING that: + /** Every adapter already gates its own shutdown, so the router only has to stop UNDOING that: * a late acquire must not clear `allAdaptersClosed` and fan a session back out to closed * adapters. Once closed, the router stays closed. */ async acquire(input: Parameters[0]) { if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } - const adapter = this.requireAgent(input.identity) - const acquired = await adapter.acquire(input) + const registration = this.requireAgent(input.identity) + const acquired = await registration.adapter.acquire(input) if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } - this.routes.set(input.identity.sessionId, { adapter, state: 'live' }) + this.routes.set(input.identity.sessionId, { registration, state: 'live' }) return acquired } @@ -48,13 +56,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi const route = this.routes.get(input.sessionId) if (route) { try { - return (await route.adapter.releaseAcquisition?.(input)) === true + return (await route.registration.adapter.releaseAcquisition?.(input)) === true } finally { this.routes.delete(input.sessionId) } } let released = false - for (const candidate of Object.values(this.adapters)) { + for (const candidate of this.adapters()) { released = (await candidate.releaseAcquisition?.(input)) === true || released } return released @@ -63,11 +71,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - rewindSupport: NonNullable = (sessionId, agent) => - this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? { - supported: false, - reason: 'unsupported' + /** The owner's declared rewind, narrowed by its adapter for this session; never widened. */ + rewindSupport: NonNullable = ( + sessionId, + agent + ) => { + const owner = this.capabilityOwner(sessionId, agent) + if (!owner?.definition.capabilities.rewind) { + return { supported: false, reason: 'unsupported' } } + return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } + } rewind: NonNullable = (input) => this.owner(input.sessionId).rewind?.(input) ?? @@ -96,12 +110,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - supportsThreadGoal = (sessionId: string, agent?: string): boolean => - this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false - - recordsContextUsage = (sessionId: string, agent?: string): boolean => - this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false - stopBackgroundTasks: NonNullable = ( input ) => { @@ -132,9 +140,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) atRestCommands: StructuredAgentSessionAtRestCommands = { - read: (record) => this.adapters[record.provider].atRestCommands?.read(record), + read: (record) => + this.agents.registration(record.provider)?.adapter.atRestCommands?.read(record), onChange: (listener) => { - const stops = Object.values(this.adapters).flatMap((adapter) => + const stops = this.adapters().flatMap((adapter) => adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : [] ) return () => stops.forEach((stop) => stop()) @@ -166,7 +175,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) + }) => + this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ?? + Promise.resolve(null) closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) @@ -193,8 +204,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route.state === 'stopped') { return true } - const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId) + const { adapter } = route.registration + const stop = selectStop(adapter) + const stopped = await stop?.call(adapter, sessionId) if (stopped === true) { route.state = 'stopped' return true @@ -240,25 +252,29 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter } - /** The live owner, or for a session at rest the provider it would start under. */ - private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null { - return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null) + /** The live owner, or for a session at rest the agent it would start under. */ + private capabilityOwner(sessionId: string, agent?: string): StructuredAgentRegistration | null { + const route = this.routes.get(sessionId) + if (route?.state === 'live') { + return route.registration + } + return agent ? this.agents.registration(agent) : null } private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { const route = this.routes.get(sessionId) - return route?.state === 'live' ? route.adapter : null + return route?.state === 'live' ? route.registration.adapter : null } - private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { - const adapter = this.adapterForAgent(identity.agent) - if (!adapter) { + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration { + const registration = this.agents.registration(identity.agent) + if (!registration) { throw new Error(`structured sessions do not support ${identity.agent}`) } - return adapter + return registration } - private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { - return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + private adapters(): StructuredAgentSessionAdapter[] { + return this.agents.registrations().map((registration) => registration.adapter) } } 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 9975701e704..f94a3b13420 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 @@ -270,7 +270,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise }): Promise - /** `agent` answers for a session with no child running, from the provider alone. */ + /** How this session narrows its agent's declared rewind; `agent` answers for one with no child + * running. The router applies the declaration first, so an adapter's answer never widens it. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string @@ -324,10 +325,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** Whether this session can change its goal; `agent` answers one at rest. */ - supportsThreadGoal?(sessionId: string, agent?: string): boolean - /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ - recordsContextUsage?(sessionId: string, agent?: string): boolean /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string 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 4f532865452..621e19d6f94 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 @@ -21,6 +21,7 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host' 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 = 'codex_adopting_session' @@ -236,6 +237,7 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'valid source') store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), 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 5c718da0f3b..b8013510805 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 @@ -28,6 +28,7 @@ import { 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 Code is not signed in. Sign in with the Claude CLI' @@ -130,6 +131,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 index 4b52c9446de..9106bafa107 100644 --- 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 @@ -29,6 +29,7 @@ import { } 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' @@ -85,6 +86,7 @@ function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { 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), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index bbdc2b50b2f..cfb7dfd5483 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -29,6 +29,7 @@ import { } 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 { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -71,6 +72,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index ac1111ac629..de44f4f8a2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -28,6 +28,7 @@ import { } 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' const CALLER = { callerKey: 'client-1' } @@ -67,6 +68,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 868247eb37c..88e495b5dbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -18,6 +18,7 @@ import { 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 CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -85,6 +86,7 @@ beforeEach(async () => { optionWritable = Promise.resolve() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 5e4ac75b066..744eba82c50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -83,6 +84,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), 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 bf473bfc6df..201242a95e2 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 @@ -19,6 +19,7 @@ import { endExitedStructuredAgentSessionChildUnderSerialize } from './structured import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_788_727_031_330 const roots: string[] = [] @@ -121,6 +122,7 @@ describe('Claude root-exit stop', () => { const deps = { store, adapter, + agents: NO_STRUCTURED_AGENTS, // The one database the store and the journal share, as the runtime installs them. journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 198c2418cf9..449b2645d60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -22,6 +22,8 @@ import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state' import { + claudeFrame as frame, + claudeWasSent as wrote, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -42,6 +44,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -104,6 +107,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), @@ -181,10 +185,6 @@ async function dispatch(clientMessageId: string) { return { state: submission?.dispatchState, reason: submission?.reason } } -function frame(connection: FakeConnection, message: Record): void { - connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) -} - /** Sends a message and lets Claude open its turn and write one reply; returns the turn's id. */ async function openTurn(connection: FakeConnection, text = 'Write a long reply.'): Promise { const clientMessageId = await send(text) @@ -246,10 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -function wrote(connection: FakeConnection, text: string): boolean { - return connection.sent.some((message) => JSON.stringify(message).includes(text)) -} - const INTERRUPTED_RESULT = { type: 'result', subtype: 'error_during_execution', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index cfc2c27ca6b..afb4ac1c1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -20,6 +20,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -83,6 +84,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 9e10a753f07..6107c402b88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -30,6 +30,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -70,6 +71,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 4ac8e1e35ad..a027d9b1caf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -29,6 +29,7 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } @@ -49,6 +50,7 @@ beforeEach(() => { exitObservedFirst = false closeCalls = 0 host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: state.store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 1dbbac5282d..7ee9d070f4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -71,6 +72,7 @@ beforeEach(async () => { }) disposeSession = vi.spyOn(adapter, 'disposeSession') host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: (log = recordingStructuredAgentSessionLogger()).logger, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index b1654ff7079..e51b8e672f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -42,6 +42,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { conversationCommandBlocked } from './structured-conversation-command-admission' @@ -153,6 +154,7 @@ export type StructuredAgentSessionCommandHandoverContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext @@ -310,13 +312,17 @@ function commandBlocked( ctx: StructuredAgentSessionCommandHandoverContext, body: AgentJournalMessageItem ): SubmissionRejectionFact | null { - if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND) { return agentSessionFailureFact('commandRefused') } const record = ctx.record() if (!record) { return agentSessionFailureFact('hostFault') } + // The declaration admits it, as it does the advertised command list; a client may send it anyway. + if (!ctx.agents.capabilities(record.provider)?.compact) { + return agentSessionFailureFact('commandRefused') + } const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') return refusal ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index a1e11e0c42d..52e0d8e46c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -63,6 +64,7 @@ beforeEach(async () => { log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 059567386a6..e10d215dc80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -26,6 +26,7 @@ import { } 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' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -93,6 +94,7 @@ function host( overrides: Partial = {} ): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, 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 a8e0f4be94f..ac3e264ee0c 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 @@ -49,6 +49,7 @@ import { import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -159,6 +160,7 @@ async function seedClaudeToolTurn(): Promise { function openHost(overrides: Partial): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 b9601ed1a8d..e3aaa965edc 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 @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause @@ -45,6 +46,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise @@ -238,6 +240,7 @@ export class StructuredAgentSessionDeliveryLoop { journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, + agents: this.deps.agents, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), 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 31e7bb71859..a469af94120 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 @@ -25,6 +25,7 @@ import { 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): stderr tail' @@ -53,6 +54,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 index d6590d6c61a..e510916b097 100644 --- 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 @@ -23,6 +23,7 @@ import { 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' @@ -49,6 +50,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 index a4a12bfce7e..f308b790398 100644 --- 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 @@ -26,6 +26,7 @@ import { 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' } @@ -135,6 +136,7 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 5da54bebfc5..80460bb72fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -59,6 +59,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const loop = new StructuredAgentSessionDeliveryLoop({ sessions, adapter: deps.adapter, + agents: deps.agents, serialize: input.serialize, trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, 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 bceb95e4dbd..ab632de9dda 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 @@ -161,7 +161,7 @@ export async function setStructuredAgentSessionOption( }, run: (ctx) => atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) + ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) : plan.run(ctx) }, openForProviderWrite(context, params.envelope) 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 index c444f52c031..f8f9745f890 100644 --- 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 @@ -21,6 +21,7 @@ import { 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' } @@ -68,6 +69,7 @@ describe('abandoning a structured agent-session host', () => { setOption: async () => undefined } const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, 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 96fc601134b..ab748f866b5 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 @@ -31,6 +31,7 @@ import { } from './structured-agent-session-host-test-data' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const journals = createTrackedJournalOpener() @@ -157,6 +158,7 @@ beforeEach(async () => { recoveryCapsule = new TrackedTestRecoveryCapsule(root) log = recordingProductionStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: log.logger, store, adapter: adapter(), 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 7016e91e17c..7bf5c850331 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 @@ -22,6 +22,8 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -32,7 +34,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider readable: boolean /** Why the journal did not open, as a read would be refused. Host-side only: never published. */ openRefusal?: AgentSessionWireRefusal @@ -109,6 +111,8 @@ export type StructuredAgentSessionHostSession = { export type StructuredAgentSessionHostDeps = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + /** The agents this runtime drives; what each declares is read here, never from the adapter. */ + agents: StructuredAgentRegistry /** Optional advisory recovery storage, independent of conversation backups. */ recoveryCapsule?: AgentSessionRecoveryCapsule /** The host's one chat journal database. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 16251f3cd40..22791df28fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -30,6 +30,7 @@ import { 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' let root: string let store: AgentSessionRecordStore @@ -134,6 +135,7 @@ describe('attach', () => { } })) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, @@ -544,6 +546,7 @@ describe('restart', () => { ) { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), ...adapterOverrides }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index 5a384ce2bb9..a8f45664a1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -26,6 +26,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) @@ -94,6 +95,7 @@ async function relaunchWithMismatchedCopy(): Promise }) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: 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 81b7200c304..268b71d152a 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 @@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- 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' } @@ -97,6 +98,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 00b9f617891..918afba6c5b 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 @@ -26,6 +26,7 @@ import { 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' } @@ -44,6 +45,7 @@ let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 index b8bda4c1b78..5719dd172cf 100644 --- 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 @@ -39,6 +39,7 @@ import { 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 PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -64,6 +65,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 3ed04e1f2fe..3cb9762cf39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -49,7 +49,9 @@ import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. @@ -73,6 +75,7 @@ export type AgentSessionMutationSessionPreparation = export type AgentSessionMutationRequest = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry logger: StructuredAgentSessionLogger callerKey: string envelope: AgentSessionMutationEnvelope @@ -175,7 +178,7 @@ export async function admitAndRunAgentSessionMutation( } const fence = record.lease.runtimeFence - const context = turnContext(request, journal, fence) + const context = mutationTurnContext(request, journal, record) if (admission.decision === 'replay') { const replayed = replayRecordedOperation(request, context, admission.row) if (replayed !== 'rerun') { @@ -262,7 +265,7 @@ async function answerRecordedOperation( // The row is gone since: the first-run path decides it from scratch. return 'rerun' } - const context = turnContext(request, journal, current.record.lease.runtimeFence) + const context = mutationTurnContext(request, journal, current.record) return replayRecordedOperation(request, context, current.decision.row) } @@ -317,32 +320,3 @@ function admitWithoutLedgerRow( }) return { admission, record: evaluated.record } } - -function turnContext( - request: AgentSessionMutationRequest, - journal: AgentSessionJournal, - fence: number -): AgentSessionTurnContext { - const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options - return { - sessionId: request.envelope.sessionId, - journal, - fence, - adapter: request.adapter, - logger: request.logger, - ...(persistedOptions ? { persistedOptions } : {}), - persistOptions: (options) => - request.store - .replaceSessionOptions({ - sessionId: request.envelope.sessionId, - fence, - options, - now: request.now() - }) - .then(() => undefined), - resolvedBy: request.callerKey, - publish: () => request.publish(journal), - ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), - now: () => request.now() - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 216a952f830..fb12399c237 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -59,6 +59,7 @@ export function mutateStructuredAgentSession( admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts index 7e9ffcd2ac2..b995b1a373f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts @@ -18,6 +18,7 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' let host: StructuredAgentSessionHost let acquire: Mock @@ -89,7 +90,10 @@ describe('a mutation while an import is owed', () => { it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { await attach() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - Object.assign(host.deps.adapter, { changeThreadGoal, supportsThreadGoal: () => true }) + Object.assign(host.deps.adapter, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) const owed = oweImport() providerOpensTurn('turn-g', 901) providerEvents().appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts new file mode 100644 index 00000000000..cae4399cb65 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -0,0 +1,38 @@ +// The context an admitted mutation's plan runs in, built from the request that admitted it. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export function mutationTurnContext( + request: AgentSessionMutationRequest, + journal: AgentSessionJournal, + record: AgentSessionRecord +): AgentSessionTurnContext { + const fence = record.lease.runtimeFence + const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options + return { + sessionId: request.envelope.sessionId, + journal, + fence, + adapter: request.adapter, + agents: request.agents, + agent: record.provider, + logger: request.logger, + ...(persistedOptions ? { persistedOptions } : {}), + persistOptions: (options) => + request.store + .replaceSessionOptions({ + sessionId: request.envelope.sessionId, + fence, + options, + now: request.now() + }) + .then(() => undefined), + resolvedBy: request.callerKey, + publish: () => request.publish(journal), + ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), + now: () => request.now() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts index f3ce31243da..ea2a0cd1461 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts @@ -23,6 +23,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const relaunchedRoots: string[] = [] @@ -80,6 +81,7 @@ async function relaunch(): Promise { }) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index a5c44bdb0d0..2c62c3f945d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -19,6 +19,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' 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' async function context(): Promise { return { @@ -35,6 +36,8 @@ async function context(): Promise { stateDirectory: join(hostTestState().root, 'settlement') }), fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: adapter(), persistOptions: async () => {}, resolvedBy: 'test', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 47aecdaa00c..55f3a6fbc72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,6 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -44,9 +45,12 @@ describe('an option picked while the chat is at rest', () => { const persistOptions = vi.fn(async () => {}) const refused = await recordStructuredAgentSessionOptionIntent( { - getRecord: () => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. - ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + store: { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + agents: claudeAndCodexAgents() }, { sessionId: SESSION, persistOptions, publish: () => {} }, { key: 'notAnOption', value: 'x' } 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 index 62482870271..67085dfbcf2 100644 --- 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 @@ -23,6 +23,7 @@ import { 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 DEFAULT_MODEL = 'gpt-default' @@ -126,6 +127,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: router, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 5657fcfb4d8..fbb3801d0e0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -9,6 +9,7 @@ import { } from '../agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const SESSION = 'session-1' @@ -35,7 +36,12 @@ describe('options at rest', () => { const resting = { child: null, params: { provider: 'codex' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index e17d5ddf074..314f436f1d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -7,17 +7,15 @@ import { refuse, - type AgentSessionModelOption, type AgentSessionOptionResult, type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' -import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' -import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -25,28 +23,28 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick -/** With no catalog for the account, the list a running child falls back to: Claude's built-in - * models. A Codex child has no such list, so none: the client fills the current model from its - * own unknown-model defaults, unchanged from before this list was shared. */ -function restingFallbackModels( - provider: AgentSessionRecord['provider'] -): AgentSessionModelOption[] | null { - return provider === 'claude' ? claudeFallbackModelOptions() : null +/** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ +function restingOptionRules( + agents: Pick, + record: AgentSessionRecord +): StructuredAgentDefinition['restingOptions'] | null { + return agents.definition(record.provider)?.restingOptions ?? null } async function readStructuredAgentSessionOptionsAtRest( - deps: Pick, + deps: Pick, sessionId: string ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } + const rules = restingOptionRules(deps.agents, record) const catalog = (await deps.modelCatalog ?.read({ agent: record.provider, sessionId }) .catch(() => null)) ?? { origin: 'unknown' as const } - const listed = - catalog.origin === 'unknown' ? restingFallbackModels(record.provider) : catalog.models + // With no catalog for the account, the list a running child of this agent falls back to. + const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models const models = listed ?? [] const saved = record.options ?? {} const fastMode = @@ -59,11 +57,10 @@ async function readStructuredAgentSessionOptionsAtRest( saved.model ?? (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ?? '' - // As a live child answers: the pick, else what Claude runs for this model when none is sent. - // A live Codex child answers only the effort its thread reported, never the model's default. + // As a live child answers: the pick, else the model's default where the agent reports that. const effort = saved.effort ?? - (record.provider === 'claude' + (rules?.effortDefaultsToModel ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -81,16 +78,15 @@ async function readStructuredAgentSessionOptionsAtRest( /** Records a pick for the next start. Only a key the provider would accept is kept. */ export async function recordStructuredAgentSessionOptionIntent( - store: Pick, + deps: { + store: Pick + agents: Pick + }, ctx: Pick, input: { key: string; value: string } ): Promise> { - const record = store.getRecord(ctx.sessionId) - const accepted = - record?.provider === 'codex' - ? isCodexTurnOptionKey(input.key) - : record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key) - if (!record || !accepted) { + const record = deps.store.getRecord(ctx.sessionId) + if (!record || !restingOptionRules(deps.agents, record)?.acceptsKey(input.key)) { return { ok: false, refusal: refuse( @@ -114,7 +110,7 @@ export async function readStructuredAgentSessionOptions( >, sessionId: string ): Promise { - const { adapter, store } = context.deps + const { adapter, agents, store } = context.deps const live = await context.serialize(sessionId, async () => { const session = await context.openConversation(sessionId).catch((error: unknown) => { throw journalOpenReadRefusal(error, context.deps.logger, sessionId) @@ -133,6 +129,7 @@ export async function readStructuredAgentSessionOptions( const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase const agent = session.params.provider + const capabilities = agents.capabilities(agent) return { ...options, rewind: @@ -142,11 +139,9 @@ export async function readStructuredAgentSessionOptions( supported: false, reason: 'unsupported' }), - conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'], - ...(adapter.supportsThreadGoal?.(sessionId, agent) - ? { threadGoal: { current: session.journal.threadGoal() } } - : {}), - ...(adapter.recordsContextUsage?.(sessionId, agent) + conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], + ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), + ...(capabilities?.contextUsage ? { contextUsage: { current: session.journal.contextUsage() } } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 8b2586a9ccb..73887ceef69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -33,6 +33,7 @@ import { } 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' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -94,6 +95,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index d2348e2acfd..b132f8852bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -18,6 +18,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -91,6 +92,8 @@ function context( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', 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 index c61d2bd117f..6f0c544f377 100644 --- 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 @@ -42,6 +42,7 @@ import { 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 CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -85,6 +86,7 @@ const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (inpu function startHost(): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 index 57d249d2dbe..09421a97cc5 100644 --- 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 @@ -18,6 +18,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- 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[] = [] @@ -58,6 +59,7 @@ function createHost( probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] ): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: claudeAdapter(), 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 index 2922c3fdb2e..18cfaba38fd 100644 --- 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 @@ -24,6 +24,7 @@ import { } 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' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -76,6 +77,7 @@ beforeEach(async () => { }) 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. 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 15af150c12f..ce07eeb65a7 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 @@ -12,10 +12,8 @@ export function adapterSupportsCreate( if (adapter.supportsCreate) { return adapter.supportsCreate(location, agent) } - if (agent !== 'codex') { - return false - } - // Older Codex adapters exposed only location support; absence still fails closed here. + // An adapter with no per-agent gate serves the one agent it was built for, so only its location + // support can refuse; absence still fails closed here. return adapter.supportsLocation?.(location) ?? false } @@ -38,6 +36,6 @@ export function adapterSupportsRecord( if (adapter.supportsCreate) { return adapter.supportsCreate(record.location, record.provider) } - // Old Codex records stay readable unless the adapter explicitly rejects their location. - return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true) + // A record stays readable unless the adapter explicitly rejects its location. + return adapter.supportsLocation?.(record.location) ?? true } 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 00319720942..668209d1513 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 @@ -29,6 +29,7 @@ import { 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' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } type RigSendOptions = { internal?: true; source?: AgentMessageSource } @@ -76,6 +77,7 @@ export async function createQueuedMessageTestRig( const store = await openTestAgentSessionRecordStore(root) const makeHost = () => new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -133,11 +135,7 @@ export async function createQueuedMessageTestRig( sessionId, clientOperationId, expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId, - fields - }) + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) } } 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 314e4012199..772746008e4 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 @@ -24,6 +24,7 @@ import { 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' } @@ -83,6 +84,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), 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 index a16fa4f21d8..5ff32f09e3c 100644 --- 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 @@ -31,6 +31,7 @@ import { 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 METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -90,6 +91,7 @@ async function createHarness(options: { attached?: boolean } = {}) { setOption } const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, 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 new file mode 100644 index 00000000000..96f1a390155 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -0,0 +1,160 @@ +// 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 } 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 } +} + +function readAtRest(agents: StructuredAgentRegistry) { + const resting = { + child: null, + params: { provider: RECORD.provider }, + journal: { threadGoal: () => null, contextUsage: () => null } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { + adapter: 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) +} + +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' }) + 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']) + }) + + 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 index 6fc7b6c2d52..24546a97bcc 100644 --- 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 @@ -24,6 +24,7 @@ import { 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' const CALLER = { callerKey: 'client-1' } @@ -43,31 +44,32 @@ beforeEach(async () => { stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) cancelTurn = vi.fn(async () => ({ cancelled: true })) store = await openTestAgentSessionRecordStore(root) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: fence > 1 ? ('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 = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: fence > 1 ? ('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, - supportsThreadGoal: () => true, - stopBackgroundTasks - }, + adapter, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index c0cc13125ca..7b85104b8a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -23,6 +23,7 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { startAgent } from './structured-agent-session-restart-interruption-test-harness' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const REQUESTED = 'Cancellation requested.' @@ -112,6 +113,7 @@ describe('a Stop pressed again', () => { await store.renewLeases([]) const relaunchedStore = await openTestAgentSessionRecordStore(root) const relaunched = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: relaunchedStore, adapter: adapter(), 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 bd21fa3ff33..c16fa93048b 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 @@ -21,6 +21,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -162,6 +163,7 @@ export async function createRestTestRig( } const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(), logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 59d478cbb40..cf442256bf5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -40,6 +40,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -132,6 +133,7 @@ export async function interruptedRestart( const clock = { now: NOW + 1 } const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, adapter: { 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 index 38a47a06ebe..335d5a14686 100644 --- 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 @@ -29,6 +29,7 @@ import { 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' } @@ -59,6 +60,7 @@ function adapter(): StructuredAgentSessionAdapter { function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), 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 8d5d5d54cbb..6776a2f9e89 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 @@ -28,6 +28,7 @@ import { 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: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -80,6 +81,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), 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 6e60651d4bd..420a93b5832 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 @@ -34,6 +34,7 @@ import { 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: 'desktop' } let directory: string @@ -97,6 +98,7 @@ beforeEach(async () => { closeSession: async () => true } host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 6003bba605a..6fe719c8cbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -36,6 +36,7 @@ export async function rewindStructuredAgentSession( const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope: params.envelope, @@ -64,7 +65,7 @@ export async function rewindStructuredAgentSession( run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) const record = store.getRecord(sessionId)! - const support = ctx.adapter.rewindSupport?.(sessionId) + const support = ctx.adapter.rewindSupport?.(sessionId, ctx.agent) if (!support?.supported) { return rewindRefusal(support?.reason ?? 'unsupported') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 55acadfc562..a88cfc96e52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -12,6 +12,7 @@ import { performSend, type AgentSessionTurnContext } from './structured-agent-se import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const journals = createTrackedJournalOpener() @@ -63,6 +64,8 @@ describe('structured send idempotency', () => { sessionId: 'session-1', journal, fence: 2, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'caller', @@ -104,6 +107,8 @@ describe('structured send idempotency', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'caller', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index c30c33d1f43..631b9cac3c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -26,6 +26,7 @@ import { } 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' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -71,6 +72,7 @@ async function relaunchAfterCrashMidTurn( throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, 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 index c51219ae8e6..f1bbd673cd6 100644 --- 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 @@ -26,6 +26,7 @@ import { } 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' const CALLER = { callerKey: 'client-1' } @@ -72,6 +73,7 @@ beforeEach(async () => { })) 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) 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 6cf15ca088c..f41d2e190c7 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 @@ -31,6 +31,7 @@ import { 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' } @@ -148,6 +149,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 index bfc7db94ebd..6981ff79cf1 100644 --- 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 @@ -28,6 +28,7 @@ import { 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' } @@ -109,6 +110,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => accepted()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -138,6 +140,7 @@ describe('settled attach retry', () => { throw new Error('journal path unavailable') }) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -196,6 +199,7 @@ describe('settled attach retry', () => { }) const mintSpawnToken = vi.fn(() => 'spawn-safe') host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -241,6 +245,7 @@ describe('settled attach retry', () => { let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -272,6 +277,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -330,6 +336,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), 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 0afc0a05077..77b48a375a4 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 @@ -29,6 +29,7 @@ import { 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 Code is not signed in. Sign in with the Claude CLI' @@ -122,6 +123,7 @@ beforeEach(async () => { ) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index caabf348133..3d579330a4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -30,6 +30,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const writes = vi.hoisted(() => ({ failing: false })) @@ -84,6 +85,7 @@ async function relaunch( // The lease-reconcile entries the host logs, by the failure each reports. const leaseReconcileLogged = vi.fn() const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => { if (fields.scope === 'lease-reconcile') { 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 f8df44ce307..d514035812f 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 @@ -13,6 +13,7 @@ import { } from '../../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -48,6 +49,8 @@ async function stopWhileTheTurnOpens( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: 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 521f9d888cf..7f5704a09eb 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 @@ -16,6 +16,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -76,6 +77,8 @@ async function stopEndingTheChild(options: { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), 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 index 9db5951336e..d0b286b24b9 100644 --- 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 @@ -42,6 +42,7 @@ 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' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -77,6 +78,7 @@ 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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 5ab883380d5..2c45cc73a41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -16,6 +16,7 @@ import { threadGoalPlan } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -66,15 +67,19 @@ function appendGoalRow( function context( journal: AgentSessionJournal, - adapter: Partial + double: Partial, + agent = 'codex' ): AgentSessionTurnContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods. + const adapter = double as StructuredAgentSessionAdapter return { logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods. - adapter: adapter as StructuredAgentSessionAdapter, + adapter, + agents: claudeAndCodexAgents(adapter), + agent, persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), @@ -88,7 +93,9 @@ describe('performThreadGoalChange', () => { const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const result = await performThreadGoalChange( - context(journal, { changeThreadGoal, supportsThreadGoal: () => true }), + context(journal, { + changeThreadGoal + }), { clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } } ) @@ -116,8 +123,7 @@ describe('performThreadGoalChange', () => { it('removes the objective when the provider refuses the goal', async () => { const journal = await openJournal() const ctx = context(journal, { - changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }), - supportsThreadGoal: () => true + changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }) }) const result = await performThreadGoalChange(ctx, { @@ -141,8 +147,7 @@ describe('performThreadGoalChange', () => { const ctx = context(journal, { changeThreadGoal: async () => { throw new Error('connection closed') - }, - supportsThreadGoal: () => true + } }) await expect( @@ -163,8 +168,7 @@ describe('performThreadGoalChange', () => { async () => ({ ok: true as const }) ] const ctx = context(journal, { - changeThreadGoal: () => attempts.shift()!(), - supportsThreadGoal: () => true + changeThreadGoal: () => attempts.shift()!() }) const input = { clientOperationId: 'op-9', @@ -192,7 +196,9 @@ describe('performThreadGoalChange', () => { it('journals nothing for a status change or clear', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal + }) await performThreadGoalChange(ctx, { clientOperationId: 'op-3', @@ -208,10 +214,10 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const result = await performThreadGoalChange( - context(journal, { changeThreadGoal, supportsThreadGoal: () => false }), - { clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } } - ) + const result = await performThreadGoalChange(context(journal, { changeThreadGoal }, 'claude'), { + clientOperationId: 'op-5', + change: { kind: 'set', objective: 'Ship it' } + }) expect(result).toMatchObject({ ok: false, @@ -232,7 +238,9 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() await appendGoalRow(journal, { status: 'complete' }) const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal + }) await performThreadGoalChange(ctx, { clientOperationId: 'op-6', @@ -262,7 +270,9 @@ describe('performThreadGoalChange', () => { it('reads a goal the provider reported, still landing when the set arrives, as the one it replaces', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal + }) // Issued, not yet landed: the set's read takes its place behind it in the journal's queue. const landing = appendGoalRow(journal, { status: 'active' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index f1612e03db2..0948a86e8fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -47,7 +47,7 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { + if (!ctx.adapter.changeThreadGoal || !ctx.agents.capabilities(ctx.agent)?.threadGoal) { return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 0cbf5b8ce6c..b79191c9ac3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -13,6 +13,7 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -58,6 +59,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -112,6 +115,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -157,6 +162,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn: vi.fn(async () => ({ cancelled: false })) } as unknown as StructuredAgentSessionAdapter, @@ -191,6 +198,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -229,6 +238,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -310,6 +321,8 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), @@ -393,6 +406,8 @@ describe('the note a Stop writes', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index a291943fc0f..2d5a9a133e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -43,6 +43,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -52,6 +53,10 @@ export type AgentSessionTurnContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + /** What each agent declares; the session's own answer is `agents.capabilities(agent)`. */ + agents: StructuredAgentRegistry + /** The session's agent. */ + agent: string logger: StructuredAgentSessionLogger persistedOptions?: Readonly> persistOptions: (options: Readonly>) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts index 170f58eba2f..c95edf51602 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts @@ -26,6 +26,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const relaunchedRoots: string[] = [] @@ -58,6 +59,7 @@ async function relaunchWith( edit(openTestJournalHostDatabase(relaunched).db) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 20c5abd595e..8f6037c6d9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -26,6 +26,7 @@ import { } 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' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -85,6 +86,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), 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 242f7c22877..768b6724caa 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 @@ -55,6 +55,7 @@ import { claudeProviderHandle, 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 DEAD_OWNER: AgentSessionProcessIdentity = { @@ -126,6 +127,7 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise { /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { 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 b50166cd98d..d2cb969bf1d 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 @@ -9,6 +9,7 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter @@ -66,6 +67,7 @@ let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { store = await openTestAgentSessionRecordStore(generationRoot()) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 73093da4744..9672df6a487 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -43,7 +44,7 @@ export type ConversationReplacement = { sourceSessionId: string sessionId: string workspaceId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider } const clearFingerprintOf = (sessionId: string) => @@ -105,6 +106,7 @@ export function runStructuredConversationCommand( return admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 41031cb3b3e..6113b17ddec 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -24,8 +24,12 @@ import { attach, CALLER, envelope, - hostTestState + hostTestState, + replaceHostTestState } from './structured-agent-session-host-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { StructuredAgentRegistry } from './structured-agent-registry' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' import { HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, @@ -333,6 +337,33 @@ it('says only that the compaction failed when the provider refused it without wo ) }) +it('refuses the command for an agent that does not declare compaction, whatever its adapter has', async () => { + const declared = CODEX_STRUCTURED_AGENT.capabilities + const agents = new StructuredAgentRegistry([ + { + definition: { + ...CODEX_STRUCTURED_AGENT, + capabilities: { ...declared, compact: false, threadGoal: false, rewind: false } + }, + adapter: state.host.deps.adapter + } + ]) + replaceHostTestState({ + store: state.store, + host: new StructuredAgentSessionHost({ ...state.host.deps, agents }) + }) + state = hostTestState() + await attach() + const params = compactParams() + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', failure: { kind: 'commandRefused' } } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() +}) + it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() const events = state.acquire.mock.calls.at(-1)?.[0].events diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index d34bb5ac87f..9a73ae2d8c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,9 +1,10 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredProviderSessionOwnership = { sessionId: string workspaceId: string - provider: 'claude' | 'codex' + provider: AgentSessionHandleProvider providerSessionId: string lease: AgentSessionLease } diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 2bf67f0b4f8..cb360070191 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -24,11 +24,16 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' +import { + agentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) return resolveStructuredAgentSessionCreateSupport({ @@ -45,7 +50,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR /** The saved selection a new chat here starts with. createSupport reports it too, so a client's * picker shows what create will run; one resolver keeps the two from drifting. */ structuredAgentSessionLaunchSeedOptions( - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Record | undefined { return resolveStructuredLaunchSeedOptions( this.requireStore().getSettings().nativeChatSessionOptions, @@ -93,7 +98,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider callerKey?: string resumeFrom?: { providerSessionId: string } }): Promise { @@ -125,38 +130,38 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR * Same resolver as the create intent above — never a second copy. */ async resolveStructuredAgentAccountHome( - agent: 'claude' | 'codex' - ): Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> { + agent: AgentSessionHandleProvider + ): Promise { const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) if (agent === 'claude') { - return { - variable: 'CLAUDE_CONFIG_DIR', - path: resolveStructuredClaudeAccountHomePath({ + return agentSessionAccountHome( + agent, + resolveStructuredClaudeAccountHomePath({ launchEnv, wslDistro: null, getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) }) - } + ) } - return { - variable: 'CODEX_HOME', + return agentSessionAccountHome( + agent, // Read-only resolver, never launch prep: a picker mount or discovery read // must not sync homes, start bridges, or clear an account selection. - path: await resolveStructuredCodexAccountHomePath({ + await resolveStructuredCodexAccountHomePath({ launchEnv, resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn }) - } + ) } protected async resolveStructuredAgentSessionIntent( input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider callerKey?: string resumeFrom?: { providerSessionId: string } }, @@ -215,10 +220,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: { - variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: adoption ? adoption.accountHomePath : selectedAccountHomePath - }, + accountHome: agentSessionAccountHome( + input.agent, + adoption ? adoption.accountHomePath : selectedAccountHomePath + ), ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { 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 1992d3c785a..f1faf8e1b38 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 @@ -16,6 +16,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' 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 SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -187,6 +188,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 20abf16a69c..46419629347 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -34,6 +34,7 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { claudeAndCodexDeclared } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CLIENT = { clientId: 'device-1', @@ -386,9 +387,9 @@ describe('options at rest', () => { it('answers the provider-level features of a chat at rest (P2-17)', async () => { await restingChat() + // A runtime that declares Codex's goal and rewind, as production registers it. + setStructuredAgentSessionHost(await rig.restart({ agents: claudeAndCodexDeclared() })) Object.assign(rig.host.deps.adapter, { - supportsThreadGoal: (_id: string, agent?: string) => agent === 'codex', - recordsContextUsage: (_id: string, agent?: string) => agent === 'claude', rewindSupport: (_id: string, agent?: string) => agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } }) 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 index 7b13887e1aa..549b0a92dd2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../../shared/agent-session-failu 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 CONNECTION = 'connection-1' const CLIENT = { @@ -81,6 +82,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index bea8b49039b..7c8eb63c32e 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -41,6 +41,7 @@ import { closeStructuredAgentSessionChild } from '../../structured-agent-session 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 WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -95,6 +96,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { async function openHost(): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: providerAdapter(), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b5efd11efad..9fe0506658a 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -27,11 +27,14 @@ import { type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { readClaudeManagedAccountGateSettings, @@ -304,12 +307,17 @@ async function installOnJournal( ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore }) - const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { + const agents = new StructuredAgentRegistry([ + { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } + ]) + const adapter = new StructuredAgentSessionAdapterRouter(agents, async () => { await Promise.all([codex.closeAll(), claude.closeAll()]) }) host = new StructuredAgentSessionHost({ store, adapter, + agents, recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory), journalDatabase, claimKeyId: deps.claimKeyId, diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 47fa6d20100..52649785c59 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -11,7 +11,9 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { fakeClaude } from '../claude/claude-structured-session-test-support' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { HOST_TEST_NOW, @@ -121,13 +123,13 @@ beforeEach(async () => { onLifecycleEvent: () => {} }) log = recordingStructuredAgentSessionLogger() + // Only Claude sessions are attached here; the router supplies the production create gate. + const agents = claudeAndCodexAgents({ claude: adapter, codex: adapter }) host = new StructuredAgentSessionHost({ + agents, logger: log.logger, store, - // Only Claude sessions are attached here; the router supplies the production create gate. - adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => - adapter.closeAll() - ), + adapter: new StructuredAgentSessionAdapterRouter(agents, () => adapter.closeAll()), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, @@ -145,10 +147,7 @@ afterEach(async () => { describe('Claude rewind is unsupported', () => { it('refuses a rewind RPC before writing any rewind record', async () => { - expect(adapter.rewindSupport(HOST_TEST_SESSION)).toEqual({ - supported: false, - reason: 'unsupported' - }) + expect(CLAUDE_STRUCTURED_AGENT.capabilities.rewind).toBe(false) expect(await host.rewind(caller, rewindParams(fence()))).toMatchObject({ ok: false, refusal: { rewindReason: 'unsupported' } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b89546d8a42..3cd6f6bfca0 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -114,7 +114,7 @@ export function NativeChatStructuredSession( }), [controller, historyPhase, props.agent, props.sessionId] ) - const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + const agentLabel = structuredAgentLabel(props.agent) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ outbox: controller.outbox, submissions: controller.submissions, diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 83e7d2a5a36..b8c65b6e17d 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -10,6 +10,7 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { isHostModelListingWaitInFlight, joinHostModelListingWait, @@ -61,7 +62,7 @@ export function useHostModelCatalogUpgrade(args: { isHostModelListingWaitInFlight(waitKey) ) useEffect(() => { - if (!enabled || !optionCatalog || (agent !== 'claude' && agent !== 'codex')) { + if (!enabled || !optionCatalog || !isAgentSessionHandleProvider(agent)) { return } let stale = false diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 215f189280d..b8838a2f439 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -96,9 +96,8 @@ export function useStructuredAgentSessionOptions(args: { target, optionCatalog, enabled: args.isVisible, - // A resumed conversation may keep its own model, so only a new one runs the listed default — - // and only Codex's listing names the configured model; Claude's settings or env may pick another. - namesDefault: launch?.kind === 'new' && agent === 'codex', + // A resumed conversation may keep its own model, so only a new one runs the listed default. + namesDefault: launch?.kind === 'new' && optionCatalog?.hostListingNamesConfiguredModel === true, ...(launch?.worktree ? { worktree: launch.worktree } : {}), fence, activeOptionRecordRef, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx index fcb474f2acc..b3f4a938fb2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -94,4 +94,19 @@ describe('useStructuredAgentSession transcript', () => { // The list places rows by the same items, so the notice joins the cut turn. expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) }) + + it('names any other agent by its own label, never as Claude', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'gemini', + target: { kind: 'local' }, + isVisible: true + }) + ) + + expect(result.current.messages.at(-1)).toMatchObject({ + blocks: [{ text: expect.stringMatching(/^Gemini stopped while/) }] + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 6d73bc3ce05..463e178cd20 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -40,7 +40,6 @@ import { structuredAgentSessionStartFailureFacts } from './structured-agent-sess import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -174,7 +173,7 @@ export function useStructuredAgentSession(args: { const transcriptItems = useMemo( () => withNativeChatCutTurnNotices(transportState.journalItems, { - agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + agentName: structuredAgentLabel(agent) }), [agent, transportState.journalItems] ) @@ -198,7 +197,7 @@ export function useStructuredAgentSession(args: { runConversationCommand: (command: AgentSessionConversationCommand) => structuredConversationCommands.sendStructuredConversationCommand({ command, - agentName: structuredAgentLabel(agent === 'codex' ? 'codex' : 'claude'), + agentName: structuredAgentLabel(agent), pending: commandPending, blocked: Boolean( transportState.turnId || diff --git a/src/renderer/src/lib/structured-agent-session-launch-label.ts b/src/renderer/src/lib/structured-agent-session-launch-label.ts index 2c733aebfa5..2ccea1158bb 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-label.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-label.ts @@ -1,6 +1,6 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentType } from '../../../shared/agent-status-types' import { getAgentCatalog } from '@/lib/agent-catalog' -export function structuredAgentLabel(agent: AgentSessionHandleProvider): string { +export function structuredAgentLabel(agent: AgentType): string { return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent } diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index c6efb318f20..a8ee2b30d8b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,4 +1,7 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from '../../../shared/agent-session-provider-handle' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' @@ -93,7 +96,7 @@ function validRecord(value: unknown): value is Omit< (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && typeof sessionId === 'string' && sessionId.length > 0 && - (agent === 'claude' || agent === 'codex') && + isAgentSessionHandleProvider(agent) && (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 1d61a35f72d..0a4c1e5aaad 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -11,7 +11,10 @@ import type { } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from '../../../shared/agent-session-provider-handle' import { beginPairedStructuredLaunch, openDeclinedStructuredLaunchTerminal, @@ -37,7 +40,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The host the chat is created on; every later operation on the tab reads it. */ executionHostId: ExecutionHostId sessionId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider targetGroupId?: string activate?: boolean }): Tab { @@ -161,7 +164,7 @@ function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisio if (!handle) { return null } - if (!worktreeId || (args.plan.agent !== 'claude' && args.plan.agent !== 'codex')) { + if (!worktreeId || !isAgentSessionHandleProvider(args.plan.agent)) { throw new Error('A provisional structured launch needs its workspace and provider.') } try { diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts new file mode 100644 index 00000000000..047f88f19b3 --- /dev/null +++ b/src/shared/agent-session-account-home.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + agentConfigDirectoryVariable, + agentSessionAccountHome, + isAgentConfigDirectoryVariable +} from './agent-session-account-home' +import { isPersistedAgentSessionRecord } from './agent-session-record' +import { agentSessionRecordFixture } from './agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from './agent-session-record-stored-form' + +describe('agent session account home', () => { + it('pins the variables older builds wrote and read', () => { + expect(agentConfigDirectoryVariable('claude')).toBe('CLAUDE_CONFIG_DIR') + expect(agentConfigDirectoryVariable('codex')).toBe('CODEX_HOME') + expect(JSON.stringify(agentSessionAccountHome('codex', '/home/dev/.codex'))).toBe( + '{"variable":"CODEX_HOME","path":"/home/dev/.codex"}' + ) + }) + + it('admits only a variable some agent declares', () => { + expect(isAgentConfigDirectoryVariable('CLAUDE_CONFIG_DIR')).toBe(true) + expect(isAgentConfigDirectoryVariable('CODEX_HOME')).toBe(true) + expect(isAgentConfigDirectoryVariable('PATH')).toBe(false) + expect(isAgentConfigDirectoryVariable(undefined)).toBe(false) + }) + + it('refuses a stored record whose account home names an undeclared variable', () => { + const record = encodeAgentSessionRecord(agentSessionRecordFixture()) + expect(isPersistedAgentSessionRecord(record)).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } + }) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts new file mode 100644 index 00000000000..2a9be3d1f12 --- /dev/null +++ b/src/shared/agent-session-account-home.ts @@ -0,0 +1,45 @@ +/** + * The account an agent session is pinned to: the agent's config directory, and the environment + * variable that points the agent at it. + * + * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), so a + * record stores it beside the path and a new agent names its own here. Stored values are exactly + * what older builds wrote and read. + */ + +import type { AgentSessionHandleProvider } from './agent-session-provider-handle' + +/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ +export type AgentSessionAccountHome = { + /** Environment variable naming the agent's config directory. */ + variable: string + /** Host-resolved absolute path in the execution host's own path syntax. */ + path: string +} + +const CONFIG_DIRECTORY_VARIABLES: Readonly> = { + claude: 'CLAUDE_CONFIG_DIR', + codex: 'CODEX_HOME' +} + +const KNOWN_CONFIG_DIRECTORY_VARIABLES: ReadonlySet = new Set( + Object.values(CONFIG_DIRECTORY_VARIABLES) +) + +/** The variable an account home of this agent pins. */ +export function agentConfigDirectoryVariable(agent: AgentSessionHandleProvider): string { + return CONFIG_DIRECTORY_VARIABLES[agent] +} + +/** The account home of `agent` at `path`. */ +export function agentSessionAccountHome( + agent: AgentSessionHandleProvider, + path: string +): AgentSessionAccountHome { + return { variable: agentConfigDirectoryVariable(agent), path } +} + +/** Admits only a variable some agent declares: a record's variable becomes a child's environment. */ +export function isAgentConfigDirectoryVariable(value: unknown): value is string { + return typeof value === 'string' && KNOWN_CONFIG_DIRECTORY_VARIABLES.has(value) +} diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts new file mode 100644 index 00000000000..285fcfe2d45 --- /dev/null +++ b/src/shared/agent-session-capabilities.ts @@ -0,0 +1,29 @@ +/** + * What a structured agent can do, declared before any session runs. + * + * Each agent's definition declares one record, and readers branch on it instead of on the agent's + * name. A running session may narrow a declared capability (a Codex thread with legacy history + * cannot rewind) but never widens one. Plain data, so a host can send it to clients as it is. + */ +export type AgentSessionCapabilities = { + /** Roll the conversation back to before a turn. */ + rewind: boolean + /** Compact the conversation's context on request: offers the `/compact` command. */ + compact: boolean + /** A goal the user can set and change on the thread. */ + threadGoal: boolean + /** Turns report how much of the context window they used. */ + contextUsage: boolean + /** Prompts may carry images. */ + imagePrompts: boolean + /** A message sent while a turn runs: handed to the running agent at once (`inject`), or held by + * Orca and sent as the next prompt once the turn ends (`queue`). */ + steering: 'inject' | 'queue' + /** + * Who applies the user's permission setting. `provider`: the agent is launched with it and + * confines its own work. `orca`: Orca answers every permission request the agent sends by the + * session's setting (a protocol-driven agent's value). The agent's own settings still decide + * WHEN it asks, so only what it asks about is covered, never a sandbox. + */ + approvalEnforcement: 'provider' | 'orca' +} diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 9cc7cfb1fa9..6925b92a869 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -181,6 +181,7 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption { export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { supportsWorkerLaunchPreferences: true, + hostListingNamesConfiguredModel: true, // Why: Codex model access depends on auth. Keep this seed short and allow // unknown persisted ids to pass through instead of claiming a complete list. models: [ diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index cd426987b81..7c9f5d1d87d 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -76,6 +76,9 @@ export type AgentSessionOptionCatalog = { * model while the picker, which never reads launch args, still names the CLI default. * A real fix means threading `modelApply.removeAgentArgs` through to the surface. */ defaultModelIsCliDefault?: true + /** The host's model listing names the model the account is configured to run, so a new chat + * may run the listed default. Off where the agent's own settings or env may pick another. */ + hostListingNamesConfiguredModel?: true listModels?: { command: string parse: (stdout: string) => CatalogModel[] diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 3bdef0ea1e8..9ed80d875ec 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -26,6 +26,12 @@ import { type AgentSessionProviderHandleLink } from './agent-session-provider-handle' import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' +import { + isAgentConfigDirectoryVariable, + type AgentSessionAccountHome +} from './agent-session-account-home' + +export type { AgentSessionAccountHome } from './agent-session-account-home' export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const @@ -44,13 +50,6 @@ export type AgentSessionExecutionLocation = { workspaceKind: AgentSessionWorkspaceKind } -/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ -export type AgentSessionAccountHome = { - variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME' - /** Host-resolved absolute path in the execution host's own path syntax. */ - path: string -} - /** Provider launch environment captured by the host when the session is created. */ export type AgentSessionLaunchEnv = Record @@ -235,8 +234,7 @@ function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccount } const home = value as Partial return ( - (home.variable === 'CLAUDE_CONFIG_DIR' || home.variable === 'CODEX_HOME') && - isBoundedString(home.path, MAX_PATH_LENGTH) + isAgentConfigDirectoryVariable(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) ) } diff --git a/src/shared/agent-session-turn-record.test.ts b/src/shared/agent-session-turn-record.test.ts index bcd14a7290f..d31a36d1c76 100644 --- a/src/shared/agent-session-turn-record.test.ts +++ b/src/shared/agent-session-turn-record.test.ts @@ -110,6 +110,22 @@ describe('legacyAgentJournalTurnStatusBody', () => { ).toBe('Codex turn outcome unverifiable') }) + it('names any other agent by its own name, never as Codex', () => { + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'gemini').text).toBe( + 'Gemini turn completed' + ) + expect(legacyAgentJournalTurnStatusBody(turn, 'legacy:gemini:s:turn-lifecycle%3At1').text).toBe( + 'Gemini turn completed' + ) + // Nothing names the agent: say so rather than guess one. + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1').text).toBe( + 'Agent turn completed' + ) + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'codex').text).toBe( + 'Codex turn completed' + ) + }) + it('carries the verdict to a client that predates the turn item', () => { // The downgrade is the only carrier an old client gets. Its own text still // reads "completed" — that copy is the pre-existing contract and this change diff --git a/src/shared/agent-session-turn-record.ts b/src/shared/agent-session-turn-record.ts index 2932eef5c1f..eb4b6c49a56 100644 --- a/src/shared/agent-session-turn-record.ts +++ b/src/shared/agent-session-turn-record.ts @@ -11,6 +11,7 @@ import { type AgentJournalTurnOutcome } from './agent-session-journal-types' import { agentTurnLifecycleText } from './agent-turn-lifecycle-text' +import { tuiAgentDisplayName } from './tui-agent-display-names' export function readAgentJournalTurn( body: AgentJournalItemBody | undefined @@ -67,9 +68,7 @@ export function legacyAgentJournalTurnStatusBody( itemId: string, sessionAgent?: string | null ): AgentJournalStatusItem { - const agent = - (sessionAgent ?? (itemId.startsWith('legacy:claude:') ? 'claude' : 'codex')) === 'claude' - ? 'Claude' - : 'Codex' - return { kind: 'status', text: agentTurnLifecycleText(agent, turn.state), turnLifecycle: turn } + const agent = sessionAgent ?? /^legacy:([^:]+):/.exec(itemId)?.[1] ?? null + const name = agent === null ? 'Agent' : (tuiAgentDisplayName(agent) ?? agent) + return { kind: 'status', text: agentTurnLifecycleText(name, turn.state), turnLifecycle: turn } } diff --git a/src/shared/agent-turn-lifecycle-text.ts b/src/shared/agent-turn-lifecycle-text.ts index f8bc2be568b..3f641f27ad9 100644 --- a/src/shared/agent-turn-lifecycle-text.ts +++ b/src/shared/agent-turn-lifecycle-text.ts @@ -3,7 +3,7 @@ import type { AgentJournalTurnLifecycleState } from './agent-session-journal-typ /** Fallback text on a lifecycle row for readers that render status text raw. * Must never overstate what the host knows: an unobserved end is not "completed". */ export function agentTurnLifecycleText( - agent: 'Claude' | 'Codex', + agent: string, state: AgentJournalTurnLifecycleState ): string { switch (state) { diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 4c25de1a1bd..16f6f4f4583 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,4 +1,8 @@ import { sha256 } from './sha256' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from './agent-session-provider-handle' function canonicalize(value: unknown): string { if (value === null || typeof value !== 'object') { @@ -41,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider resumeFrom?: { providerSessionId: string } tabId?: string }): string { @@ -66,11 +70,7 @@ export function showStructuredAgentSessionChoice(input: { workspaceSupport: boolean agent: string }): boolean { - return ( - input.hostCapability && - input.workspaceSupport && - (input.agent === 'claude' || input.agent === 'codex') - ) + return input.hostCapability && input.workspaceSupport && isAgentSessionHandleProvider(input.agent) } export function createStructuredAgentSessionOperationId( diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index a780cff5a4e..0ea071e4e17 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -56,3 +56,8 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { /** Canonical agent id list derived from the exhaustive display-name record, * so shared modules can enumerate agents without importing renderer code. */ export const ALL_TUI_AGENTS = Object.keys(TUI_AGENT_DISPLAY_NAMES) as readonly TuiAgent[] + +/** The display name of `agent`, or null when it names no TUI agent. */ +export function tuiAgentDisplayName(agent: string): string | null { + return Object.entries(TUI_AGENT_DISPLAY_NAMES).find(([id]) => id === agent)?.[1] ?? null +} 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 d427eb5109a..f1076ac1df4 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 @@ -65,6 +65,7 @@ import { import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -550,6 +551,7 @@ describe('cross-version structured agent sessions', () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -777,6 +779,7 @@ describe('cross-version structured agent sessions', () => { async function bootHost(generation: string): Promise { store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), 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 abf2f994004..9fc2255514b 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -27,6 +27,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-ty import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -38,6 +39,7 @@ let closeSession: Mock