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 695acefbb6f8..c5a05cfb5569 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 5473719ce114..c1ce921caca2 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 000000000000..ca37f7abfad3 --- /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 0228b1171683..edb7c2d0919a 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 979b5ae5963e..95e43e2a276b 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 0e3176a1813c..ade13231d318 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 02cf46aadc83..2b5d28c99bcb 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 722858828f25..d0263b0c100e 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 000000000000..8aab5137b114 --- /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 19945c9a6c89..b48cc4cae113 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 c63d78439aba..f942b5f83a88 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 1c5a034fd63c..11d9a2ffca31 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 4f9f9a2ab0d2..6d0d9ad74195 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 28b1f8729d3c..70c59acd77b4 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 638b0f54272f..a240cfa3e6dc 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 1b89afd3997e..56c5244e2ff2 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 000000000000..8744ccfde1fc --- /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 000000000000..c6f305548ce9 --- /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 3f1b4046f542..99900a2a8935 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 000000000000..c16c035ef6d8 --- /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 000000000000..4d1dffb8a391 --- /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 076e2de7540d..7f8ad5c91067 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 5edc3793d026..e7d7ba2cf4c0 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 9975701e7047..f94a3b134201 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 4f532865452a..621e19d6f949 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 5c718da0f3be..b80135108056 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 4b52c9446dec..9106bafa107a 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 bbdc2b50b2fd..cfb7dfd54834 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 ac1111ac629e..de44f4f8a2ba 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 868247eb37ce..88e495b5dbdf 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 5e4ac75b0662..744eba82c505 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 bf473bfc6df0..201242a95e20 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 198c2418cf93..449b2645d601 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 cfc2c27ca6be..afb4ac1c1c11 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 9e10a753f07e..6107c402b889 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 4ac8e1e35ad6..a027d9b1caf7 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 1dbbac5282d8..7ee9d070f4c9 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 b1654ff70790..e51b8e672f96 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 a1e11e0c42dd..52e0d8e46c0c 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 059567386a6d..e10d215dc80a 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 a8e0f4be94f9..ac3e264ee0cb 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 b9601ed1a8de..e3aaa965edc9 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 31e7bb718591..a469af94120e 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 d6590d6c61a7..e510916b0973 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 a4a12bfce7ec..f308b7903983 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 5da54bebfc56..80460bb72fa9 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 bceb95e4dbde..ab632de9dda8 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 c444f52c031e..f8f9745f8905 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 96fc601134b4..ab748f866b5b 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 7016e91e17c6..7bf5c850331b 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 16251f3cd401..22791df28fa8 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 5a384ce2bb9e..a8f45664a1d8 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 81b7200c304e..268b71d152aa 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 00b9f617891d..918afba6c5b3 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 b8bda4c1b78c..5719dd172cfa 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 3ed04e1f2fec..3cb9762cf39f 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 216a952f8309..fb12399c2370 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 7e9ffcd2ac2b..b995b1a373fd 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 000000000000..cae4399cb654 --- /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 f3ce31243dad..ea2a0cd14617 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 a5c44bdb0d0a..2c62c3f945da 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 47aecdaa00cc..55f3a6fbc72f 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 624828702713..67085dfbcf22 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 5657fcfb4d8d..fbb3801d0e0e 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 e17d5ddf074a..314f436f1d25 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 8b2586a9ccba..73887ceef69d 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 d2348e2acfd3..b132f8852bb8 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 c61d2bd117f0..6f0c544f377c 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 57d249d2dbe4..09421a97cc56 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 2922c3fdb2ec..18cfaba38fd0 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 15af150c12f1..ce07eeb65a72 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 003197209424..668209d15138 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 314e4012199f..772746008e41 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 a16fa4f21d8b..5ff32f09e3c7 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 000000000000..96f1a390155f --- /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 6fc7b6c2d52a..24546a97bcc0 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 c0cc13125ca4..7b85104b8a52 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 bd21fa3ff339..c16fa93048ba 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 59d478cbb40a..cf442256bf53 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 38a47a06ebea..335d5a14686a 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 8d5d5d54cbb8..6776a2f9e898 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 6e60651d4bdd..420a93b58320 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 6003bba605a1..6fe719c8cbda 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 55acadfc562f..a88cfc96e52b 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 c30c33d1f434..631b9cac3c37 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 c51219ae8e64..f1bbd673cd62 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 6cf15ca088c7..f41d2e190c7d 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 bfc7db94ebdd..6981ff79cf13 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 0afc0a05077d..77b48a375a47 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 caabf348133c..3d579330a4df 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 f8df44ce3073..d514035812f2 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 521f9d888cfd..7f5704a09eb0 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 9db5951336e5..d0b286b24b91 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 5ab883380d54..2c45cc73a416 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 f1612e03db2c..0948a86e8fd6 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 0cbf5b8ce6c1..b79191c9ac39 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 a291943fc0fd..2d5a9a133e4c 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 170f58eba2fe..c95edf516029 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 20c5abd595e8..8f6037c6d9c7 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 242f7c228778..768b6724caa2 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 b50166cd98d9..d2cb969bf1d3 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 73093da47447..9672df6a4879 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 41031cb3b3eb..6113b17ddec6 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 d34bb5ac87fe..9a73ae2d8c0e 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 2bf67f0b4f88..cb360070191e 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 1992d3c785a0..f1faf8e1b388 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 20abf16a69cd..46419629347b 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 7b13887e1aa4..549b0a92dd26 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 bea8b49039b1..7c8eb63c32e3 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 b5efd11efad1..9fe0506658ad 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 47fa6d201009..52649785c59f 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 b89546d8a424..3cd6f6bfca00 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 83e7d2a5a364..b8c65b6e17d1 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 215f189280d4..b8838a2f4399 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 fcb474f2acc6..b3f4a938fb25 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 6d73bc3ce050..463e178cd202 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 2c733aebfa53..2ccea1158bb3 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 c6efb318f20b..a8ee2b30d8be 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 1d61a35f72d6..0a4c1e5aaad9 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 000000000000..047f88f19b30 --- /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 000000000000..2a9be3d1f12a --- /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 000000000000..285fcfe2d457 --- /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 9cc7cfb1fa94..6925b92a8697 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 cd426987b81f..7c9f5d1d87dd 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 3bdef0ea1e85..9ed80d875ecd 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 bcd14a7290fd..d31a36d1c764 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 2932eef5c1fc..eb4b6c49a56a 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 f8bc2be568b5..3f641f27ad96 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 4c25de1a1bdc..16f6f4f45831 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 a780cff5a4e9..0ea071e4e17d 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 d427eb5109a8..f1076ac1df4d 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 abf2f994004a..9fc2255514b2 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