diff --git a/src/main/claude/claude-structured-acquire-catalog.ts b/src/main/claude/claude-structured-acquire-catalog.ts new file mode 100644 index 000000000000..e4144a22c014 --- /dev/null +++ b/src/main/claude/claude-structured-acquire-catalog.ts @@ -0,0 +1,11 @@ +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import type { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' + +/** A Claude session's catalog, keyed by the config directory it launched under. */ +export function claudeAcquireCatalogAccess( + store: AgentModelCatalogStore | undefined, + claudeConfigDir: string | null +) { + return agentModelCatalogSessionAccess(store, CLAUDE_STRUCTURED_AGENT, claudeConfigDir) +} diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index ca37f7abfad3..991d681c988c 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -1,9 +1,12 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { agent: 'claude', + handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CLAUDE_CONFIG_DIR', capabilities: { // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. rewind: false, 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 edb7c2d0919a..92cf562b9dcd 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 @@ -144,6 +144,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { const modelCatalog = createAgentModelCatalogService({ store, getRecord: () => record, + drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) }) const resting = { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 95e43e2a276b..e20c1bd8b8ee 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -34,7 +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' +import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -251,7 +251,7 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } - const pinned = agentConfigDirectoryVariable('claude') + const pinned = CLAUDE_STRUCTURED_AGENT.accountHomeVariable if (record.accountHome.variable !== pinned) { throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) } @@ -266,7 +266,7 @@ export function createClaudeStructuredLaunchResolver( gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } - // A Claude record's chain holds only Claude handles; the record store refuses anything else. + // A Claude record's chain holds only Claude handles; the attach admission refuses anything else. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( head && diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 39d2e0cdbe2f..bbe23bb0d2ce 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -41,7 +41,7 @@ import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' import { bindClaudeConnectionJournalControls, createClaudeJournalFailureHandler @@ -257,11 +257,7 @@ export async function acquireClaudeSession({ }) const session = publication.session liveSession = session - const catalogAccess = agentModelCatalogSessionAccess( - deps.modelCatalog, - 'claude', - launch.claudeConfigDir - ) + const catalogAccess = claudeAcquireCatalogAccess(deps.modelCatalog, launch.claudeConfigDir) if (catalogAccess) { session.catalogAccess = catalogAccess } diff --git a/src/main/codex/codex-structured-acquire-catalog.ts b/src/main/codex/codex-structured-acquire-catalog.ts index 6a420f2dd990..0e66d5c4ae02 100644 --- a/src/main/codex/codex-structured-acquire-catalog.ts +++ b/src/main/codex/codex-structured-acquire-catalog.ts @@ -11,12 +11,13 @@ import { type CodexSessionOptionCatalog } from './codex-structured-model-catalog' import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export function codexAcquireCatalogAccess( deps: Pick, launch: Pick ): CodexSessionCatalogAccess | undefined { - return agentModelCatalogSessionAccess(deps.modelCatalog, 'codex', launch.codexHome) + return agentModelCatalogSessionAccess(deps.modelCatalog, CODEX_STRUCTURED_AGENT, launch.codexHome) } /** The one catalog read a fast-mode restore needs, store-first. Null degrades diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 8aab5137b114..317acdbd3a35 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,8 +1,11 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { agent: 'codex', + handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CODEX_HOME', capabilities: { // A thread with legacy, unpaginated history narrows this to unsupported once it runs. rewind: true, diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index b48cc4cae113..18db5def05de 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,7 +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' +import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -86,7 +86,7 @@ export function createCodexStructuredLaunchResolver( ) { throw new Error('codex structured sessions require Windows process creation-time proof') } - const pinned = agentConfigDirectoryVariable('codex') + const pinned = CODEX_STRUCTURED_AGENT.accountHomeVariable if (accountHome.variable !== pinned) { throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`) } @@ -95,7 +95,7 @@ export function createCodexStructuredLaunchResolver( // concern, and the permission posture they used to smuggle in is derived per acquisition. const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) - // A Codex record's chain holds only Codex handles; the record store refuses anything else. + // A Codex record's chain holds only Codex handles; the attach admission refuses anything else. const resumeThreadId = head?.handle.nativeId ?? null // The same saved options every turn sends, so the thread and its turns name one model. const model = record.options?.model diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index b28c25647ffd..b51b4e44d5b8 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -130,6 +130,7 @@ describe('Codex session options through the host catalog store', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), probes: { codex: hungProbe } }) 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 70c59acd77b4..d59f0d124c33 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,8 +4,7 @@ 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' +import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' /** * Everything that changes which models a listing can answer with: the agent, @@ -14,7 +13,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-p * key is corrected by the next refresh, never by the fingerprint. */ export type AgentModelCatalogIdentity = { - agent: 'claude' | 'codex' + agent: string accountHomeVariable: string accountHomePath: string /** Null on the native host; WSL distros each carry their own CLI. */ @@ -57,7 +56,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: AgentSessionHandleProvider, + agent: Pick, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { @@ -66,8 +65,8 @@ export function agentModelCatalogSessionAccess( return { store, fingerprint: agentModelCatalogFingerprint({ - agent, - accountHomeVariable: agentConfigDirectoryVariable(agent), + agent: agent.agent, + accountHomeVariable: agent.accountHomeVariable, accountHomePath, wslDistro: null }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index 034a6fc8b4aa..d47e9efbec56 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -5,6 +5,7 @@ import type { AgentSessionOptionChoice } from '../../../shared/agent-session-wire' import type { AgentModelCatalogEntry } from './agent-model-catalog-store' +import { isStructuredAgentId } from '../../../shared/agent-session-provider-handle-encoding' const SCHEMA_VERSION = 1 const SAVE_COALESCE_MS = 500 @@ -67,7 +68,7 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { const row = asRecord(value) if ( !row || - (row.agent !== 'claude' && row.agent !== 'codex') || + !isStructuredAgentId(row.agent) || typeof row.fingerprint !== 'string' || (row.origin !== 'live-session' && row.origin !== 'probe') || typeof row.fetchedAt !== 'number' || diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index ee0b5550da72..14b3ce613ebb 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -55,6 +55,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => record('/homes/a'), + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -73,6 +74,26 @@ describe('agent model catalog service', () => { }) }) + it('probes as for no record when this build cannot start the record as it is pinned', async () => { + const store = new AgentModelCatalogStore() + const probe = vi.fn(async (_home: string) => listing('gpt-a')) + // A Codex record pinning Claude's variable: its path is not a Codex home to probe under. + const pinned = { ...record('/x'), accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/x' } } + const drivesRecord = vi.fn(() => false) + const service = createAgentModelCatalogService({ + store, + getRecord: () => pinned, + drivesRecord, + resolveAccountHome: async () => CODEX_HOME('/homes/selected'), + probes: { codex: probe } + }) + + await service.read({ agent: 'codex', sessionId: 'session-1' }) + + expect(drivesRecord).toHaveBeenCalledWith(pinned) + expect(probe).toHaveBeenCalledExactlyOnceWith('/homes/selected') + }) + it('an account switch with no record reads and prewarms the NEW account, never the old entry', async () => { const store = new AgentModelCatalogStore() // The old account listed under its own fingerprint before the switch. @@ -82,6 +103,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/new'), probes: { codex: probe } }) @@ -107,6 +129,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected') }) const result = await service.read({ agent: 'codex' }) @@ -129,6 +152,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => sessionRecord, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected') }) const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) @@ -143,6 +167,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => record('/homes/a'), + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/a'), probes: { codex: probe } }) @@ -164,6 +189,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => { throw new Error('no store yet') }, @@ -189,6 +215,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -385,6 +412,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected') }) expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({ @@ -401,6 +429,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -418,6 +447,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), workspaceMayOverrideDefaultModel }) 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 873f652be0b0..3cba2c9e6a64 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 @@ -3,7 +3,6 @@ import type { AgentSessionAccountHome, AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -17,15 +16,18 @@ import type { export type AgentModelCatalogServiceDeps = { store: AgentModelCatalogStore getRecord: (sessionId: string) => AgentSessionRecord | undefined + /** Whether this build can start the record's agent as the record pins it; a record it cannot + * names no account a probe may start that agent's CLI under. */ + drivesRecord: (record: AgentSessionRecord) => boolean /** 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: AgentSessionHandleProvider) => Promise + resolveAccountHome: (agent: string) => Promise /** Session-less listers, one per agent that has one on this host. */ - probes?: Partial> + probes?: Readonly>> /** Whether the workspace's own config could pick a model other than the listed default. */ workspaceMayOverrideDefaultModel?: (input: { - agent: 'claude' | 'codex' + agent: string workspacePath: string accountHomePath: string }) => Promise @@ -33,7 +35,7 @@ export type AgentModelCatalogServiceDeps = { export type AgentModelCatalogService = { read: (params: { - agent: 'claude' | 'codex' + agent: string sessionId?: string /** Where a new chat would run; null when one was named but is not a local directory. */ workspacePath?: string | null @@ -60,7 +62,7 @@ function resultFromEntry( /** A named workspace keeps the listed default only when none of its own config can replace it. */ async function workspaceKeepsListedDefault( deps: AgentModelCatalogServiceDeps, - agent: 'claude' | 'codex', + agent: string, workspacePath: string | null | undefined, accountHomePath: string | null ): Promise { @@ -93,7 +95,8 @@ export function createAgentModelCatalogService( return { async read(params) { const record = params.sessionId ? deps.getRecord(params.sessionId) : undefined - const scoped = record && record.provider === params.agent ? record : undefined + const scoped = + record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined let fingerprint: string let accountHomePath: string | null if (scoped) { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index 3e416d1cba32..b16a29bff357 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -20,7 +20,7 @@ export const AGENT_MODEL_CATALOG_VALIDATION_MIN_AGE_MS = 60_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 export type AgentModelCatalogEntry = { - agent: 'claude' | 'codex' + agent: string fingerprint: string models: AgentSessionModelOption[] fastModeSupport?: AgentSessionFastModeSupport @@ -153,7 +153,7 @@ export class AgentModelCatalogStore { recordSuccess( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, success: AgentModelCatalogSuccess ): AgentModelCatalogEntry | null { const entry = this.writeSuccess(fingerprint, agent, success, ++this.nextListingOrder) @@ -163,7 +163,7 @@ export class AgentModelCatalogStore { private entryFromSuccess( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, success: AgentModelCatalogSuccess ): AgentModelCatalogEntry | null { if (success.models.length === 0) { @@ -184,7 +184,7 @@ export class AgentModelCatalogStore { private writeSuccess( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, success: AgentModelCatalogSuccess, order: number ): AgentModelCatalogEntry | null { @@ -216,7 +216,7 @@ export class AgentModelCatalogStore { * whether this chat starts. Resolves with the entry on success, null on failure. */ refresh( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, lister: AgentModelCatalogLister, listModels: () => Promise ): Promise { diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index a6e51490a3ea..b9fefa7e19d0 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -53,7 +53,7 @@ function directoryMayOverride(dir: string, accountHomePath: string): Promise { 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 index 8744ccfde1fc..da783ac0749e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -5,11 +5,11 @@ // 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 { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -export type StructuredAgentDefinition = { - /** The Orca agent whose sessions this definition describes. */ - agent: string +/** `agent` names the Orca agent whose sessions this describes; the storage fields bound its records. */ +export type StructuredAgentDefinition = AgentSessionStoredAgent & { capabilities: AgentSessionCapabilities /** How a session's options read and change while no child runs. */ restingOptions: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 3b2deed8c5a1..18eff805a63b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -22,6 +22,7 @@ import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -153,6 +154,7 @@ describe('structured session acquisition options', () => { let firstJournal: AgentSessionJournal | undefined const first = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: initialStore, adapter: withHistory('created'), @@ -191,6 +193,7 @@ describe('structured session acquisition options', () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const second = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: withHistory('resumed'), @@ -223,6 +226,7 @@ describe('structured session acquisition options', () => { const recordPhase = vi.fn() const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -253,6 +257,7 @@ describe('structured session acquisition options', () => { const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -284,6 +289,7 @@ describe('structured session acquisition options', () => { const store = await openTestAgentSessionRecordStore(root) const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), @@ -315,6 +321,7 @@ describe('structured session acquisition options', () => { }) const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 const resumed = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: resumedStore, adapter: adapter({ @@ -358,6 +365,7 @@ describe('structured session acquisition options', () => { }) const created = await performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, @@ -395,6 +403,7 @@ describe('structured session acquisition options', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: failingAdapter, @@ -482,6 +491,7 @@ describe('structured session acquisition options', () => { fence: number | null ) => performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, @@ -588,6 +598,7 @@ describe('the tab a create reserves', () => { function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { return performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter({ origin: 'created' }), 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 index c16c035ef6d8..c56b875681ca 100644 --- 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 @@ -59,6 +59,8 @@ function fakeAdapter( /** An agent this build does not ship, declared the way a new adapter would declare itself. */ const PILOT: StructuredAgentDefinition = { agent: 'grok', + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', capabilities: { rewind: false, compact: false, 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 index 4d1dffb8a391..6c5869c538c3 100644 --- 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 @@ -5,12 +5,38 @@ import { StructuredAgentSessionAdapterRouter } from './structured-agent-session- 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 agent as a host over a bare adapter double registers it: who it is (so its chats + * start), and nothing it can do. */ +function declaringNothing(definition: StructuredAgentDefinition): StructuredAgentDefinition { + return { + ...definition, + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, + steering: definition.capabilities.steering, + approvalEnforcement: definition.capabilities.approvalEnforcement + }, + restingOptions: { + acceptsKey: () => false, + fallbackModels: () => null, + effortDefaultsToModel: false + } + } +} + +/** Claude and Codex declaring no capability and no resting options: what a host over a bare + * adapter double declares. */ +export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([ + { definition: declaringNothing(CLAUDE_STRUCTURED_AGENT), adapter: NO_METHODS }, + { definition: declaringNothing(CODEX_STRUCTURED_AGENT), adapter: NO_METHODS } +]) + /** 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( 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 621e19d6f949..93bfc64bae2b 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 @@ -125,6 +125,7 @@ async function attach( ) { store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index ec1289d401dd..b5a742b788bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -27,7 +27,7 @@ import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legac import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { hostCanStartRecord } from './structured-agent-session-provider-support' import { joinClosingStructuredAgentSessionChild, releaseLeaseOfEndedStructuredAgentSessionChild @@ -130,7 +130,7 @@ async function startStructuredAgentSessionAgent( 'No structured session exists by that id.' ) } - if (!adapterSupportsRecord(context.deps.adapter, record)) { + if (!hostCanStartRecord(context.deps, record)) { return refuseResume( 'structured_agent_session_unsupported', { reason: 'hostUnsupported' }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index d1b6cd280875..453a40075aed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -29,7 +29,11 @@ import { type AttachedJournal } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' +import { + adapterSupportsCreateIfDeclared, + hostCanStartRecord +} from './structured-agent-session-provider-support' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' @@ -49,6 +53,8 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + /** What decides whether this build may start a record's agent at all (`agentDrivesSession`). */ + agents: Pick logger: StructuredAgentSessionLogger authority: AgentSessionAttachAuthority callerKey: string @@ -97,8 +103,15 @@ export async function performAttach( if (!admitted.ok) { return admitted } + // Every start of every agent passes here, so this is where a record this build cannot drive (its + // transport or account variable is not its agent's) is refused; reading it never is. + const supported = (record: AgentSessionRecord | null) => + record === null + ? input.agents.definition(params.agent) !== null && + adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) + : hostCanStartRecord(input, record) // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. - if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if (!supported(store.getRecord(sessionId))) { return unsupported() } @@ -135,7 +148,7 @@ export async function performAttach( // every reservation at its effect boundary so it cannot bypass the support // gate, and release a pending reservation that support drift invalidated. reservedRecord = record - if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if (!supported(record)) { if ( record.lease.claimStatus === 'reserved' && record.lease.handoffStage === 'new-owner-proving' && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index d8f89065de4f..85dabccda7fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -135,6 +135,7 @@ async function runAttach( const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 415e261ab3ca..d6780082433a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { - AgentSessionHandleProvider, + StructuredAgentId, AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import { @@ -56,8 +56,8 @@ import { structuredAgentSessionRefusalMessage } from './structured-agent-session export type AgentSessionAttachParams = { envelope: AgentSessionMutationEnvelope location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider - agent: AgentSessionHandleProvider + provider: StructuredAgentId + agent: StructuredAgentId accountHome: AgentSessionAccountHome /** Always `native`; kept on the params because the operation fingerprint covers it. */ runtimeKind: 'native' @@ -122,6 +122,17 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor export function admitAttachOrRefuse( params: AgentSessionAttachParams ): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + // The start check judges the record's agent and the router starts `agent`'s adapter: one agent. + if (params.agent !== params.provider) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `A ${params.provider} session cannot be started as ${params.agent}.` + ) + } + } if ( params.providerHandle && !agentSessionProviderHandleBelongsTo( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 3a35b4b138ae..30c4fbbf933d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -24,7 +24,6 @@ import { import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionConversationLifetime = ReturnType< @@ -132,11 +131,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } - if (!adapterSupportsRecord(deps().adapter, record)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } return serialize(sessionId, async () => { // Read at the open itself: a read queued before quit began runs after it. if (disposed) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts new file mode 100644 index 000000000000..bbcadedabfef --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -0,0 +1,348 @@ +// A record this build cannot drive (its agent's definition now names another transport or account +// variable, or the record pins another agent's variable) stays readable; only starting its agent is +// refused, at the one launch admission every agent passes through. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentRegistry } from './structured-agent-registry' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentSessionHost } from './structured-agent-session-host' + +const NOW = 1_800_000_000_000 +const SESSION = 'grok-session' +const GROK_HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/home/dev/.grok' } +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +const GROK: StructuredAgentDefinition = { + ...CODEX_STRUCTURED_AGENT, + agent: 'grok', + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', + capabilities: { + ...CODEX_STRUCTURED_AGENT.capabilities, + rewind: false, + compact: false, + threadGoal: false + }, + restingOptions: { + acceptsKey: () => false, + fallbackModels: () => null, + effortDefaultsToModel: false + } +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. +const NO_METHODS = {} as StructuredAgentSessionAdapter +const grokRegistered = (definition: StructuredAgentDefinition = GROK) => + new StructuredAgentRegistry([{ definition, adapter: NO_METHODS }]) + +function params( + operation: string, + expectedRuntimeFence: number | null, + accountHome = GROK_HOME +): AgentSessionAttachParams { + const attach: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: `${NOW}-${operation.padStart(32, '0')}`, + expectedRuntimeFence, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'grok', + agent: 'grok', + accountHome, + runtimeKind: 'native' + } + return { + ...attach, + envelope: { + ...attach.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(attach) + }) + } + } +} + +function grokAdapter(): StructuredAgentSessionAdapter { + return { + supportsLocation: () => true, + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `grok-link-${fence}`, + handle: { transport: 'acp', agent: 'grok', nativeId: 'grok-native-1' }, + origin: fence === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + })), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +async function attach(input: { + agents: StructuredAgentRegistry + adapter: StructuredAgentSessionAdapter + params: AgentSessionAttachParams + spawnToken: string + store?: AgentSessionRecordStore +}) { + const store = input.store ?? (await openTestAgentSessionRecordStore(root!)) + const result = await performAttach({ + agents: input.agents, + logger: createStructuredAgentSessionLogger(), + store, + adapter: input.adapter, + openConversation: async (record) => { + const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))(record) + return journal + }, + authority: { + spawnToken: input.spawnToken, + claimKeyId: 'key-1', + handoffOperationId: input.params.envelope.clientOperationId, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: input.params, + now: () => NOW, + onAttached: async (attached) => { + await attached.journal.close() + } + }) + return { result, store } +} + +/** Persist and reopen a Grok chat whose original child is gone. */ +async function createdGrokChat() { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const created = await attach({ + agents: grokRegistered(), + adapter: grokAdapter(), + params: params('1', null), + spawnToken: 'spawn-a' + }) + expect(created.result).toMatchObject({ ok: true }) + const store = await openTestAgentSessionRecordStore(root) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) + return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } +} + +const UNSUPPORTED = { + ok: false, + refusal: { code: 'structured_agent_session_unsupported', details: { reason: 'hostUnsupported' } } +} + +it('refuses an unregistered agent before creating a durable record', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const adapter = grokAdapter() + const { result, store } = await attach({ + agents: new StructuredAgentRegistry([]), + adapter, + params: params('1', null), + spawnToken: 'spawn-unregistered' + }) + expect(result).toMatchObject(UNSUPPORTED) + expect(adapter.acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)).toBeNull() +}) + +it('keeps saved history readable without a registration and resumes when it returns', async () => { + const created = await createdGrokChat() + await created.store.setSessionTabVisibility(SESSION, true) + const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))( + created.store.getRecord(SESSION)! + ) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'saved-reply' }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Saved reply' }] }, + { fence: created.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + + const store = await openTestAgentSessionRecordStore(root!) + const agents = new StructuredAgentRegistry([]) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const acquire = vi.spyOn(router, 'acquire') + const host = new StructuredAgentSessionHost({ + store, + agents, + adapter: router, + journalDatabase: openTestJournalHostDatabase(root!), + claimKeyId: 'key-1', + probeOwner: async () => ({ outcome: 'pid-absent' }), + logger: createStructuredAgentSessionLogger(), + now: () => NOW + 2 + }) + try { + expect(router.supportsCreate(params('2', created.fence).location, 'grok')).toBe(false) + expect(store.listVisibleSessionIds()).toEqual([SESSION]) + await host.restoreReadableSessions([SESSION]) + expect(host.hasSession(SESSION)).toBe(true) + await expect(host.revealSession(SESSION)).resolves.toMatchObject({ + sessionId: SESSION, + agent: 'grok', + readable: true + }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(history).toMatchObject({ + ok: true, + page: { + items: expect.arrayContaining([ + expect.objectContaining({ + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Saved reply' }] + } + }) + ]) + } + }) + const before = store.getRecord(SESSION)!.lease.runtimeFence + await expect( + host.attach({ callerKey: 'client-1' }, params('2', before)) + ).resolves.toMatchObject(UNSUPPORTED) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(before) + } finally { + await host.flushAllStreamedEvents() + } + + const adapter = grokAdapter() + const { result } = await attach({ + agents: grokRegistered(), + adapter, + params: params('3', store.getRecord(SESSION)!.lease.runtimeFence), + spawnToken: 'spawn-restored', + store + }) + expect(result).toMatchObject({ ok: true }) + expect(adapter.acquire).toHaveBeenCalledOnce() + expect(store.listVisibleSessionIds()).toEqual([SESSION]) +}) + +it.each([ + ['its transport', { ...GROK, handleTransport: 'grok-native' }], + ['its account variable', { ...GROK, accountHomeVariable: 'GROK_CONFIG_DIR' }] +])( + 'keeps a chat readable after its agent changes %s, and refuses to start it', + async (_change, changed) => { + const { store, fence } = await createdGrokChat() + const adapter = grokAdapter() + + const { result } = await attach({ + agents: grokRegistered(changed), + adapter, + params: params('2', fence), + spawnToken: 'spawn-b', + store + }) + + expect(result).toMatchObject(UNSUPPORTED) + expect(adapter.acquire).not.toHaveBeenCalled() + // Still listed and readable: its tab publishes and its history opens. + expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) + expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') + } +) + +it("refuses to start a chat that pins another agent's account variable", async () => { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const adapter = grokAdapter() + + const { result } = await attach({ + agents: grokRegistered(), + adapter, + params: params('1', null, { variable: 'CODEX_HOME', path: '/home/dev/.codex' }), + spawnToken: 'spawn-a' + }) + + expect(result).toMatchObject(UNSUPPORTED) + expect(adapter.acquire).not.toHaveBeenCalled() +}) + +it('starts the same chat again once its agent is declared as it was', async () => { + const { store, fence } = await createdGrokChat() + const adapter = grokAdapter() + + const { result } = await attach({ + agents: grokRegistered(), + adapter, + params: params('2', fence), + spawnToken: 'spawn-b', + store + }) + + expect(result).toMatchObject({ ok: true }) + expect(adapter.acquire).toHaveBeenCalledOnce() +}) + +it('refuses an attach whose agent is not the session it names', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) + const adapter = grokAdapter() + const mismatched = { ...params('1', null), agent: 'codex' } + + const { result, store } = await attach({ + agents: grokRegistered(), + adapter, + params: { + ...mismatched, + envelope: { + ...mismatched.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(mismatched) + }) + } + }, + spawnToken: 'spawn-a' + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } + }) + expect(adapter.acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)).toBeNull() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 7bf5c850331b..243e2d8bea2f 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,8 +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 { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import type { StructuredAgentRegistry } from './structured-agent-registry' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -34,7 +34,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId readable: boolean /** Why the journal did not open, as a read would be refused. Host-side only: never published. */ openRefusal?: AgentSessionWireRefusal 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 22791df28fa8..0da2e682ec50 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 @@ -670,11 +670,11 @@ describe('restart', () => { expect(status).toMatchObject({ owner: 'native' }) }) - it('vouches for no owner of a chat this host cannot run', async () => { + it('reads stored ownership even when this host cannot start the provider', async () => { await attach() await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false }) - expect(() => host.handoffStatus(SESSION)).toThrow('structured_agent_session_unsupported') + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) }) it('releases a session whose owner can never be probed, signalling nothing, and starts over', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 59599c5adfe6..59dc79af0153 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,4 +1,5 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' +import type { StructuredAgentDefinition } from './structured-agent-definition' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. @@ -212,6 +213,17 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) + /** Every agent this runtime registered: what `agentSession.agents` publishes. */ + agentDefinitions = (): readonly StructuredAgentDefinition[] => this.deps.agents.definitions() + + /** Saved chats can outlive their registration; both vocabularies bound a client's audience. */ + knownAgentIds = (): readonly string[] => [ + ...new Set([ + ...this.deps.agents.definitions().map(({ agent }) => agent), + ...this.deps.store.listRecords().map(({ provider }) => provider) + ]) + ] + private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, this.sessions, 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 fbb3801d0e0e..5a458e6f2e9f 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 @@ -30,6 +30,7 @@ describe('options at rest', () => { const modelCatalog = createAgentModelCatalogService({ store: new AgentModelCatalogStore(), getRecord: () => record, + drivesRecord: () => true, resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), probes: { codex: probe } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index b9db10c1579f..06fec96ecf50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -1,22 +1,17 @@ import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' /** The `agentSession.handoffStatus` answer. Released desktop clients gate worktree activation on * `owner`, so the method outlives the terminal handoff it was named for. It reports ownership, not * liveness: a chat whose agent is stopped, idle-released or still starting is owned all the same. */ export function structuredAgentSessionOwnerStatus( - deps: Pick, + deps: Pick, sessionId: string ): AgentSessionHandoffStatus { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } - // Same refusal as reveal: a host that cannot run this chat vouches for no owner. - if (!adapterSupportsRecord(deps.adapter, record)) { - throw new Error('structured_agent_session_unsupported') - } const { handoffStage: stage, handoffOperationId: operationId } = record.lease return { owner: 'native', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 1e171e64199e..b69e90d3e777 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -18,6 +18,7 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -81,6 +82,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { setOption: unused } const input = { + agents: NO_STRUCTURED_AGENTS, store, adapter, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 1c55baa57b0f..543abc1e2537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -17,6 +17,7 @@ import { performAttach } from './structured-agent-session-attach-flow' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { 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 = 'session-alpha' @@ -84,6 +85,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -130,6 +132,7 @@ describe('processless structured session reservation', () => { })) } as unknown as StructuredAgentSessionAdapter const input = { + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -168,6 +171,7 @@ describe('processless structured session reservation', () => { const acquire = vi.fn() const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter const input = { + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -221,6 +225,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, @@ -288,6 +293,7 @@ describe('processless structured session reservation', () => { releaseAcquisition: vi.fn(async () => true) } as unknown as StructuredAgentSessionAdapter const input = { + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, 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 ce07eeb65a72..a726affd84dc 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 @@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' export function adapterSupportsCreate( adapter: StructuredAgentSessionAdapter, @@ -29,13 +30,37 @@ export function adapterSupportsCreateIfDeclared( return adapterSupportsCreate(adapter, location, agent) } -export function adapterSupportsRecord( - adapter: StructuredAgentSessionAdapter, +/** Whether this build can start the agent of `session`: the agent is registered here, its account + * variable is that agent's own (it becomes the child's environment), and every handle it holds is + * in the transport the agent's adapter speaks. A record failing this stays readable (tab, history); + * only starting its agent is refused, by the one launch admission every agent passes through. */ +export function agentDrivesSession( + agents: Pick, + session: Pick +): boolean { + const definition = agents.definition(session.provider) + return ( + definition !== null && + session.accountHome.variable === definition.accountHomeVariable && + session.providerHandleChain.every( + ({ handle }) => + handle.transport === definition.handleTransport && handle.agent === definition.agent + ) + ) +} + +/** Whether this host can start `record`'s agent: the adapter runs its location and this build + * drives it. The restart offer, a retry, the pre-send check and the start itself all ask this, so + * none offers what the start refuses. Reading the stored chat needs no adapter. */ +export function hostCanStartRecord( + deps: { + adapter: StructuredAgentSessionAdapter + agents: Pick + }, record: AgentSessionRecord ): boolean { - if (adapter.supportsCreate) { - return adapter.supportsCreate(record.location, record.provider) - } - // A record stays readable unless the adapter explicitly rejects its location. - return adapter.supportsLocation?.(record.location) ?? true + return ( + adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider) && + agentDrivesSession(deps.agents, record) + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 3c3cc124003a..b6c8444a28bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -39,7 +39,6 @@ describe('StructuredAgentSessionReadableRestorer', () => { journalDatabase: openTestJournalHostDatabase(stateDirectory), logger: recordingStructuredAgentSessionLogger().logger }, - supportsRecord: () => true, reconcile: async () => true, resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index 06bd043aec0c..dca415220275 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -1,15 +1,10 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore' import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' export class StructuredAgentSessionReadableRestorer { private restorePromise: Promise | null = null - constructor( - private readonly input: StructuredAgentSessionReadRestoreDeps & { - supportsRecord: (record: AgentSessionRecord) => boolean - } - ) {} + constructor(private readonly input: StructuredAgentSessionReadRestoreDeps) {} restore(sessionIds?: readonly string[]): Promise { this.restorePromise ??= this.restoreReadableSessions(sessionIds).catch((error: unknown) => { @@ -25,10 +20,7 @@ export class StructuredAgentSessionReadableRestorer { : null const records = this.input.openDeps.store .listRecords() - .filter( - (record) => - this.input.supportsRecord(record) && (!targetOrder || targetOrder.has(record.sessionId)) - ) + .filter((record) => !targetOrder || targetOrder.has(record.sessionId)) if (targetOrder) { records.sort( (left, right) => targetOrder.get(left.sessionId)! - targetOrder.get(right.sessionId)! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-action-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-action-result.ts new file mode 100644 index 000000000000..7b8c344b7783 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-action-result.ts @@ -0,0 +1,54 @@ +// What an explicit restart action reports: every chat it touched, and the rows left after it. + +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import type { + StructuredAgentSessionResumeCandidate, + StructuredAgentSessionResumeFailure +} from './structured-agent-session-restart-resume-set' + +/** Chats the runner turned away before a continuation started, reported as refused. */ +export function unstartedRestartRefusals( + resumed: readonly StructuredAgentSessionResumeOutcome[], + continued: readonly StructuredAgentSessionContinuationOutcome[] +): StructuredAgentSessionContinuationOutcome[] { + const refused: StructuredAgentSessionContinuationOutcome[] = [] + for (const outcome of resumed) { + const reported = (entry: StructuredAgentSessionContinuationOutcome) => + entry.sessionId === outcome.sessionId + if (outcome.outcome !== 'resumed' && !continued.some(reported) && !refused.some(reported)) { + refused.push({ + sessionId: outcome.sessionId, + outcome: 'refused', + reason: outcome.reason ?? 'agent_session_resume_refused' + }) + } + } + return refused +} + +/** The rows left after an action; a failed refresh leaves them out instead of failing the action. */ +export async function remainingRestartRows( + list: () => Promise, + listFailures: () => Promise, + logger: StructuredAgentSessionLogger +): Promise<{ + sessions?: StructuredAgentSessionResumeCandidate[] + failed?: StructuredAgentSessionResumeFailure[] +}> { + let sessions: StructuredAgentSessionResumeCandidate[] | undefined + let failed: StructuredAgentSessionResumeFailure[] | undefined + try { + sessions = await list() + failed = await listFailures() + } catch { + logger.warn('refreshing restart offers after an action failed', { + scope: 'restart-offer-refresh' + }) + } + return { + ...(sessions === undefined ? {} : { sessions }), + ...(failed === undefined ? {} : { failed }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts new file mode 100644 index 000000000000..f468d0bd12ee --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -0,0 +1,79 @@ +// A client too old to show an agent's chat is handed an audience without that agent. Every restart +// operation it calls leaves that agent's offers and failures alone, and never names them back. +// The chat here is a Codex one; an audience without Codex stands in for any agent the client +// cannot show. + +import { afterEach, expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +afterEach(() => vi.restoreAllMocks()) + +const cannotShowCodex = (agent: string) => agent !== 'codex' +const showsCodex = (agent: string) => agent === 'codex' + +function offersIn(root: string) { + return new AgentSessionRecoveryCapsule(root).list(NOW) +} + +it('neither lists nor dismisses an offer the caller cannot show', async () => { + const { host, root } = await interruptedRestart() + + expect(await host.restartResume.list(cannotShowCodex)).toEqual([]) + expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) + expect(await host.restartResume.dismiss([SESSION], cannotShowCodex)).toBe(0) + expect(await offersIn(root)).toHaveLength(1) + expect(await host.restartResume.list()).toHaveLength(1) + + // A caller that sees it dismisses it by dismissing everything it was shown. + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(1) + expect(await offersIn(root)).toEqual([]) +}) + +it('neither continues nor reserves an offer the caller cannot show, named or not', async () => { + const { host, root, acquire, dispatch } = await interruptedRestart() + + for (const named of [undefined, [SESSION]]) { + expect(await host.restartResume.continueAfterRestart(named, 'modal', cannotShowCodex)).toEqual({ + resumed: [], + continued: [], + sessions: [], + failed: [] + }) + } + expect(acquire).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + expect(await offersIn(root)).toHaveLength(1) + + const result = await host.restartResume.continueAfterRestart(undefined, 'modal', showsCodex) + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + +it('keeps a failure the caller cannot show, and leaves it out of an action reply', async () => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(await host.restartResume.listFailures()).toHaveLength(1) + + expect(await host.restartResume.listFailures(cannotShowCodex)).toEqual([]) + // Naming a chat it can act on still answers with only what it can show. + const reply = await host.restartResume.continueAfterRestart( + ['another-session'], + 'modal', + cannotShowCodex + ) + expect(reply).toMatchObject({ sessions: [], failed: [] }) + expect( + (await host.restartResume.continueAfterRestart(['another-session'], 'modal')).failed + ).toHaveLength(1) + + expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) + expect(await host.restartResume.listFailures()).toHaveLength(1) + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(1) + expect(await host.restartResume.listFailures()).toEqual([]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 93986d5988b4..5ac250001a9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -2,7 +2,7 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it. +// ask it, and the start asks the same `hostCanStartRecord`, so none offers what the start refuses. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' @@ -10,7 +10,8 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { hostCanStartRecord } from './structured-agent-session-provider-support' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionResumableSet, type StructuredAgentSessionResumableSet @@ -29,6 +30,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter + agents: Pick /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ movedOn: (marker: AgentSessionResumeMarker) => boolean /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */ @@ -38,7 +40,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, - supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + supportsRecord: (record) => hostCanStartRecord(deps, record), movedOn: deps.movedOn, savedByNewerOrca: deps.savedByNewerOrca, latestPrompt: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index f69e9ced6dc4..01abbcc53891 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -19,10 +19,9 @@ import type { } from '../../../shared/agent-session-resume-marker' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import type { + StructuredAgentSessionRestartAudience, StructuredAgentSessionResumeCandidate, StructuredAgentSessionResumeFailure } from './structured-agent-session-restart-resume-set' @@ -58,11 +57,13 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every record this host - * lists goes (a newer Orca's stay). */ + /** Named sessions forget their offer or failure; unnamed, every record this host lists goes (a + * newer Orca's stay). With an audience, only records of agents it sees go, and an unnamed + * dismissal is no fence. */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void | Promise + beforeClearAll: (audience?: StructuredAgentSessionRestartAudience) => void | Promise, + audience?: StructuredAgentSessionRestartAudience ) => Promise } @@ -76,7 +77,6 @@ export function continuationFailureOutcome( export function createStructuredAgentSessionRestartFailureLedger(deps: { capsule?: FailureCapsule getRecord: (sessionId: string) => AgentSessionRecord | null - adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean /** Whether a newer Orca saved the chat: its failure is kept for that Orca but not shown here, @@ -105,11 +105,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failure: AgentSessionResumeFailureRecord ): StructuredAgentSessionResumeFailure[] => { const record = deps.getRecord(failure.marker.sessionId) - if ( - !record || - !adapterSupportsRecord(deps.adapter, record) || - deps.savedByNewerOrca(failure.marker.sessionId) - ) { + if (!record || deps.savedByNewerOrca(failure.marker.sessionId)) { return [] } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) @@ -228,8 +224,27 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { read, list, settle, - dismiss: (sessionIds, beforeClearAll) => + dismiss: (sessionIds, beforeClearAll, audience) => deps.enqueue(async () => { + if (audience) { + // Decided under the capsule lock. A record whose chat this host cannot read names no + // agent the audience was shown, so it stays. + // An unnamed dismissal keeps a newer Orca's records too: they were never listed here. + const hidden = (marker: AgentSessionResumeMarker) => { + const record = deps.getRecord(marker.sessionId) + return ( + record === null || + !audience(record.provider) || + (sessionIds === undefined && deps.savedByNewerOrca(marker.sessionId)) + ) + } + if (sessionIds === undefined) { + await beforeClearAll(audience) + } + // No fence: no client reaches this today (the local desktop gets no audience), and a + // late write from this process is serialized behind the dismissal. + return (await deps.capsule?.dismiss(sessionIds ?? 'all', deps.now(), hidden)) ?? 0 + } if (sessionIds !== undefined) { return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } 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 cf442256bf53..5cc9f7d39c0a 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,7 +40,8 @@ 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' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import type { StructuredAgentRegistry } from './structured-agent-registry' /** 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. */ @@ -61,7 +62,9 @@ export async function interruptedRestart( /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ probeOwner: NonNullable = async () => ({ outcome: 'pid-absent' - }) + }), + /** What the relaunched host registers; restart actions scope by it. */ + agents: StructuredAgentRegistry = claudeAndCodexDeclared() ) { const previous = hostTestState() let children: AgentChildWorkView[] = [] @@ -133,7 +136,7 @@ export async function interruptedRestart( const clock = { now: NOW + 1 } const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents, logger: log.logger, store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts index e2f2087d4429..932bd700fbf7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -154,11 +154,13 @@ it('still offers a damaged chat, and files its failure when acting on it', async // "Dismiss all" ends what the person was shown. A newer Orca's offer was never shown here, so it // stays, byte for byte, for the Orca that can act on it. it.each([ - ['after the dialog listed the offers', true], - ['on a host that has not listed them yet', false] + ['after the dialog listed the offers', true, undefined], + ['on a host that has not listed them yet', false, undefined], + // A remote client that cannot show every agent dismisses through its audience. + ['for a client shown only Codex', false, (agent: string) => agent === 'codex'] ])( "dismisses every listed offer and leaves a newer Orca's hidden one as it was, %s", - async (_when, listFirst) => { + async (_when, listFirst, audience) => { const NEWER = 'session-newer-orca' // A second chat, made before the restart; a newer Orca then saves it with a row this build // can't place. @@ -189,7 +191,7 @@ it.each([ SESSION ]) } - await host.restartResume.dismiss() + await host.restartResume.dismiss(undefined, audience) const left = await new AgentSessionRecoveryCapsule(root).list(NOW) expect(left.map((offer) => offer.sessionId)).toEqual([NEWER]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 83dedb1709d3..5f4e5bb6e797 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -7,13 +7,10 @@ import { AgentSessionRefusalError, agentSessionRefusalFromReference } from '../../../shared/agent-session-wire-refusals' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createNewerOrcaChats, createStructuredAgentSessionRestartCandidateReaders @@ -24,9 +21,11 @@ import { } from './structured-agent-session-restart-failure-ledger' import { createStructuredAgentSessionRestartOperationQueue } from './structured-agent-session-restart-operation-queue' import { createStructuredAgentSessionRestartOfferRecords } from './structured-agent-session-restart-offer-records' -import type { - StructuredAgentSessionResumeCandidate, - StructuredAgentSessionResumeFailure +import { + restartRowsFor, + type StructuredAgentSessionRestartAudience, + type StructuredAgentSessionResumeCandidate, + type StructuredAgentSessionResumeFailure } from './structured-agent-session-restart-resume-set' import { resumeStructuredAgentSessionsFromRestart, @@ -46,9 +45,12 @@ import { } from './structured-agent-session-restart-offer-withdrawal' import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' +import { + remainingRestartRows, + unstartedRestartRefusals +} from './structured-agent-session-restart-action-result' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -59,33 +61,39 @@ export type StructuredAgentSessionRestartResume = { captureBeforeStop: (sessionId: string) => void confirmStopped: (sessionId: string) => void recordMarkers: () => Promise - list: () => Promise + list: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ - listFailures: () => Promise + listFailures: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** Unnamed, continues every offer the audience sees; named, only those of them. */ continueAfterRestart: ( sessionIds: readonly string[] | undefined, - owner: string + owner: string, + audience?: StructuredAgentSessionRestartAudience ) => Promise<{ resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> - /** Named sessions forget their offer or failure; unnamed, every record this host - * lists goes (a newer Orca's stay). */ - dismiss: (sessionIds?: readonly string[]) => Promise + /** Named sessions forget their offer or failure; unnamed, every record this host lists goes (a + * newer Orca's stay). An audience limits either to the agents it sees. */ + dismiss: ( + sessionIds?: readonly string[], + audience?: StructuredAgentSessionRestartAudience + ) => Promise /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ onAgentStarted: (sessionId: string) => void } export function createStructuredAgentSessionRestartResume( - deps: { - store: AgentSessionRecordStore - adapter: StructuredAgentSessionAdapter - recoveryCapsule?: AgentSessionRecoveryCapsule - logger: StructuredAgentSessionLogger - journalDatabase: Pick - }, + deps: Pick< + StructuredAgentSessionHostDeps, + 'store' | 'adapter' | 'recoveryCapsule' | 'logger' | 'agents' | 'journalDatabase' + >, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces ): StructuredAgentSessionRestartResume { @@ -113,13 +121,13 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, adapter: deps.adapter, + agents: deps.agents, movedOn: withdrawal.movedOn, savedByNewerOrca: newerOrca.has }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), getRecord: deps.store.getRecord, - adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, savedByNewerOrca: newerOrca.has, reveal: (markers) => revealMarkers(markers), @@ -141,15 +149,19 @@ export function createStructuredAgentSessionRestartResume( enqueue: enqueueRecoveryOperation }) - const list = async (): Promise => { + const list = async ( + audience?: StructuredAgentSessionRestartAudience + ): Promise => { const markers = await readMarkers() await revealMarkers(markers) // A live chat remains an offer. The user may have opened it to inspect the context and still // explicitly choose whether Orca should ask the agent to continue. const { candidates, superseded } = derive(markers, 'may-be-held') retireSuperseded(superseded) - return candidates + return restartRowsFor(candidates, audience) } + const listFailures = async (audience?: StructuredAgentSessionRestartAudience) => + restartRowsFor(await failures.list(), audience) const continuationHost: StructuredAgentSessionContinuationHost = { ...surfaces, @@ -167,17 +179,21 @@ export function createStructuredAgentSessionRestartResume( const run = async ( sessionIds: readonly string[] | undefined, owner: string, + audience: StructuredAgentSessionRestartAudience | undefined, continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. - witnesses.clear() + witnesses.clear(audience) const markers = await readActionMarkers(sessionIds) await revealMarkers(markers) const requested = new Set(sessionIds ?? markers.map((marker) => marker.sessionId)) const derived = derive(markers, 'may-be-held') retireSuperseded(derived.superseded) - const eligible = derived.candidates.filter((candidate) => requested.has(candidate.sessionId)) + // Only what the caller was shown is reserved; a named offer it cannot see is not. + const eligible = restartRowsFor(derived.candidates, audience).filter((candidate) => + requested.has(candidate.sessionId) + ) if (eligible.length === 0) { return null } @@ -233,20 +249,16 @@ export function createStructuredAgentSessionRestartResume( } } - const continueAfterRestart = async ( - sessionIds: readonly string[] | undefined, - owner: string - ): Promise<{ - resumed: StructuredAgentSessionResumeOutcome[] - continued: StructuredAgentSessionContinuationOutcome[] - sessions?: StructuredAgentSessionResumeCandidate[] - failed?: StructuredAgentSessionResumeFailure[] - }> => { + const continueAfterRestart: StructuredAgentSessionRestartResume['continueAfterRestart'] = async ( + sessionIds, + owner, + audience + ) => { const continued: StructuredAgentSessionContinuationOutcome[] = [] const verdicts: Promise[] = [] // A chat holds its slot until its agent took the continuation or its start failed, so a batch // never starts more agents at once than the runner allows; the provider's answer comes after. - const action = await run(sessionIds, owner, async (marker, continuationId) => { + const action = await run(sessionIds, owner, audience, async (marker, continuationId) => { const started = await startStructuredAgentSessionContinuation( restartContinuationDeps(continuationHost, marker), marker.sessionId, @@ -282,33 +294,15 @@ export function createStructuredAgentSessionRestartResume( } }) } - for (const outcome of resumed) { - if ( - outcome.outcome !== 'resumed' && - !continued.some((entry) => entry.sessionId === outcome.sessionId) - ) { - continued.push({ - sessionId: outcome.sessionId, - outcome: 'refused', - reason: outcome.reason ?? 'agent_session_resume_refused' - }) - } - } - let remainingCandidates: StructuredAgentSessionResumeCandidate[] | undefined - let remainingFailures: StructuredAgentSessionResumeFailure[] | undefined - try { - remainingCandidates = await list() - remainingFailures = await failures.list() - } catch { - deps.logger.warn('refreshing restart offers after an action failed', { - scope: 'restart-offer-refresh' - }) - } + continued.push(...unstartedRestartRefusals(resumed, continued)) return { resumed, continued, - ...(remainingCandidates === undefined ? {} : { sessions: remainingCandidates }), - ...(remainingFailures === undefined ? {} : { failed: remainingFailures }) + ...(await remainingRestartRows( + () => list(audience), + () => listFailures(audience), + deps.logger + )) } } @@ -318,15 +312,19 @@ export function createStructuredAgentSessionRestartResume( confirmStopped: witnesses.stopped, recordMarkers: witnesses.record, list, - listFailures: failures.list, + listFailures, // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. // "Dismiss all" keeps what this host does not list, so it reveals every record's chat first. - dismiss: (sessionIds) => - failures.dismiss(sessionIds, async () => { - witnesses.clear() - await revealEvery() - }), + dismiss: (sessionIds, audience) => + failures.dismiss( + sessionIds, + async (clearAudience) => { + witnesses.clear(clearAudience) + await revealEvery() + }, + audience + ), continueAfterRestart, onAgentStarted: withdrawal.onAgentStarted } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 9df4e392bc3c..6c1dbde9fcd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -65,6 +65,18 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa retryable: boolean } +/** The agents whose offers a caller may see and act on; absent, every agent this host runs. A + * client too old to show an agent's chat never lists, resumes, or dismisses that agent's offers. */ +export type StructuredAgentSessionRestartAudience = (agent: string) => boolean + +/** The rows `audience` may see; all of them without one. */ +export function restartRowsFor( + rows: T[], + audience: StructuredAgentSessionRestartAudience | undefined +): T[] { + return audience ? rows.filter((row) => audience(row.agent)) : rows +} + export type StructuredAgentSessionResumableSet = { candidates: StructuredAgentSessionResumeCandidate[] /** Markers the chat has provably moved past, or whose conversation forked. Every ending deletes: diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts new file mode 100644 index 000000000000..11d23efa8048 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts @@ -0,0 +1,72 @@ +// A chat whose agent this build no longer drives (its definition now names another transport) is +// still readable, but nothing offers to start it: no restart offer, and a recorded failure is not +// retryable. The offer itself is kept, so a build that drives the agent again offers it again. + +import { afterEach, expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' +import { StructuredAgentRegistry } from './structured-agent-registry' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' + +afterEach(() => vi.restoreAllMocks()) + +/** This build's agents, with Codex now speaking a protocol its existing chats were not made in. */ +function codexOnAnotherTransport(): StructuredAgentRegistry { + return new StructuredAgentRegistry( + NO_STRUCTURED_AGENTS.registrations().map((registration) => + registration.definition.agent === 'codex' + ? { + ...registration, + definition: { ...registration.definition, handleTransport: 'codex-acp' } + } + : registration + ) + ) +} + +it('does not offer to resume a chat whose agent this build no longer drives, and keeps the offer', async () => { + const { host, root, acquire } = await interruptedRestart( + undefined, + undefined, + undefined, + codexOnAnotherTransport() + ) + + expect(await host.restartResume.list()).toEqual([]) + expect(await host.restartResume.continueAfterRestart(undefined, 'modal')).toMatchObject({ + resumed: [], + continued: [] + }) + expect(acquire).not.toHaveBeenCalled() + expect(await new AgentSessionRecoveryCapsule(root).list(NOW + 1)).toHaveLength(1) +}) + +it('lists a recorded failure of such a chat as not retryable', async () => { + const { host, root, marker } = await interruptedRestart( + undefined, + undefined, + undefined, + codexOnAnotherTransport() + ) + const capsule = new AgentSessionRecoveryCapsule(root) + await capsule.beginResume([marker!.sessionId], 'operation-a', NOW + 1) + await capsule.failResume( + 'operation-a', + [ + { + sessionId: marker!.sessionId, + failedAt: NOW + 1, + outcome: 'refused', + reason: 'structured_agent_session_unsupported', + latestPrompt: '', + latestUserItemId: null + } + ], + NOW + 1 + ) + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: marker!.sessionId, retryable: false } + ]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts new file mode 100644 index 000000000000..739ad2c5a33f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' +import { marker } from './structured-agent-session-restart-resume-test-harness' + +// Every stopped session counts as cut off; what is under test is which witnesses a clear drops. +vi.mock('./structured-agent-session-working-at-teardown', () => ({ + structuredAgentSessionWorkingAtStop: (input: { sessionId: string; now: number }) => + marker({ sessionId: input.sessionId, recordedAt: input.now }) +})) + +const AGENTS: Record = { 'codex-chat': 'codex', 'grok-chat': 'grok' } + +function witnessed() { + const recorded: AgentSessionResumeMarker[][] = [] + const witnesses = createStructuredAgentSessionRestartWitnesses({ + sessions: new Map(), + getRecord: (sessionId) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. + AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, + childWork: () => undefined, + capsule: { record: async (markers) => void recorded.push([...markers]) }, + teardownId: 'teardown', + now: () => 1, + enqueue: (operation) => operation() + }) + witnesses.begin('quit') + for (const sessionId of ['codex-chat', 'grok-chat', 'unreadable-chat']) { + witnesses.beforeStop(sessionId) + witnesses.stopped(sessionId) + } + const written = async () => { + await witnesses.record() + return (recorded.at(-1) ?? []).map((each) => each.sessionId) + } + return { witnesses, written } +} + +describe('restart witnesses', () => { + it('drops every unwritten witness on an unscoped clear', async () => { + const { witnesses, written } = witnessed() + witnesses.clear() + expect(await written()).toEqual([]) + }) + + it('drops only the witnesses of agents the audience sees on a scoped clear', async () => { + const { witnesses, written } = witnessed() + witnesses.clear((agent) => agent !== 'grok') + expect(await written()).toEqual(['grok-chat', 'unreadable-chat']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 2beee1b5a59c..683fe79d9e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -24,8 +24,9 @@ export type StructuredAgentSessionRestartWitnesses = { /** The child is proven gone, so what it was doing was cut off. */ stopped: (sessionId: string) => void record: () => Promise - /** An explicit action on the offer supersedes witnesses this host has not yet written. */ - clear: () => void + /** An explicit action on the offer supersedes witnesses this host has not yet written; with an + * audience, only those of agents it sees. A session whose record is unreadable here stays. */ + clear: (audience?: (agent: string) => boolean) => void } export function createStructuredAgentSessionRestartWitnesses(deps: { @@ -40,10 +41,21 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { let trigger: AgentSessionResumeTrigger | null = null const stopping = new Map() const confirmed = new Map() - const clear = (): void => { - trigger = null - stopping.clear() - confirmed.clear() + const clear = (audience?: (agent: string) => boolean): void => { + if (!audience) { + trigger = null + stopping.clear() + confirmed.clear() + return + } + for (const witnessed of [stopping, confirmed]) { + for (const sessionId of witnessed.keys()) { + const record = deps.getRecord(sessionId) + if (record && audience(record.provider)) { + witnessed.delete(sessionId) + } + } + } } return { begin: (next) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index a574010b34ee..d148742e7a4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -7,13 +7,12 @@ * get right is which records it accepts and what it answers when the journal cannot be opened. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import * as providerSupport from './structured-agent-session-provider-support' import { revealStructuredAgentSession } from './structured-agent-session-reveal' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -39,11 +38,6 @@ afterEach(() => { }) describe('the host answer a client acts on', () => { - beforeEach(() => { - // Eligibility is the router's call; these cases are about what the answer carries. - vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(true) - }) - function record(provider: 'claude' | 'codex', workspaceId: string) { const base = recordFor(provider, 'session-answered') return { ...base, location: { ...base.location, workspaceId } } @@ -59,7 +53,7 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( - { store: { getRecord: () => stored } as never, adapter: {} as never }, + { store: { getRecord: () => stored } }, 'session-answered', open ) @@ -76,30 +70,24 @@ describe('the host answer a client acts on', () => { it('refuses a session this host holds no record for, opening nothing', async () => { const open = vi.fn(async () => undefined) await expect( - revealStructuredAgentSession( - { store: { getRecord: () => null } as never, adapter: {} as never }, - 'session-absent', - open - ) + revealStructuredAgentSession({ store: { getRecord: () => null } }, 'session-absent', open) ).rejects.toThrow('agent_session_identity_required') expect(open).not.toHaveBeenCalled() }) - it('refuses a record no adapter of this host supports', async () => { - vi.mocked(providerSupport.adapterSupportsRecord).mockReturnValue(false) + it('reveals a stored chat without requiring a provider adapter', async () => { const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( { - store: { getRecord: () => record('codex', 'workspace-1') } as never, - adapter: {} as never + store: { getRecord: () => record('codex', 'workspace-1') } }, 'session-answered', open ) - ).rejects.toThrow('structured_agent_session_unsupported') - expect(open).not.toHaveBeenCalled() + ).resolves.toMatchObject({ readable: true }) + expect(open).toHaveBeenCalledOnce() }) it('answers not-readable without refusing when the journal could not be opened', async () => { @@ -107,8 +95,7 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( { - store: { getRecord: () => record('codex', 'workspace-1') } as never, - adapter: {} as never + store: { getRecord: () => record('codex', 'workspace-1') } }, 'session-answered', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index c95d8f6d7bae..9a6e3aca5917 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -13,7 +13,6 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import { @@ -27,7 +26,7 @@ import type { /** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( - deps: Pick, + deps: { store: Pick }, sessionId: string, openConversation: (sessionId: string) => Promise ): Promise { @@ -35,11 +34,6 @@ export async function revealStructuredAgentSession( if (!record) { throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } - if (!adapterSupportsRecord(deps.adapter, record)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing // here would hide the one view of a session a user needs when its ownership is in doubt. @@ -64,7 +58,7 @@ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' + 'openDeps' | 'reconcile' | 'resolveRecovery' > & { reconcileLeases: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise @@ -78,7 +72,6 @@ export function createStructuredAgentSessionHostRestore( const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, - supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), reconcile, // The next attach or send resolves recovery again, strictly, before it acts. resolveRecovery: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 62ac5fc644ee..65bcd4c78399 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -11,6 +11,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import { isTuiAgent } from '../../../shared/tui-agent-config' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import { @@ -173,7 +174,9 @@ export function structuredAgentSessionFailureWordsContext( ): AgentSessionFailureWordsContext { const command = journal && structuredAgentSessionAwaitedCommand(journal) return { - ...(record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {}), + ...(record && isTuiAgent(record.provider) + ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } + : {}), ...(command ? { command } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts index 10a447292627..52b7df745469 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -6,7 +6,7 @@ // to change the minute a "no update" reading shows. The background-task channel, which only an // open chat subscribes to, carries every tick. -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -31,7 +31,7 @@ export type StructuredStatusChildWork = { * the two cannot disagree. */ export function structuredStatusChildWork( views: readonly AgentChildWorkView[] | undefined, - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): StructuredStatusChildWork { const running = views ? structuredRunningChildWork(views) : [] if (running.length === 0) { 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 9672df6a4879..bc43c67d2c4a 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,7 +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' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -44,7 +44,7 @@ export type ConversationReplacement = { sourceSessionId: string sessionId: string workspaceId: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId } const clearFingerprintOf = (sessionId: string) => 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 9a73ae2d8c0e..e21f84821f3b 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,10 +1,10 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' export type StructuredProviderSessionOwnership = { sessionId: string workspaceId: string - provider: AgentSessionHandleProvider + provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease } diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index 9b96a1af4be0..e15e03a1135f 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -1,4 +1,5 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { readClaudeManagedAccountGateSettings, @@ -17,7 +18,7 @@ export type StructuredAgentSessionCreateSupport = { * however wrong it was. The runtime hands over the two facts it owns and this decides. */ export function resolveStructuredAgentSessionCreateSupport(input: { - agent: 'claude' | 'codex' + agent: StructuredAgentId location: AgentSessionExecutionLocation adapterSupportsCreate: boolean getSettings: () => ClaudeManagedAccountGateSettings diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 662b93dcb2c0..b5e0ac97fe4c 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -11,12 +11,14 @@ import { agentSessionWireProviderHandle, type AgentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' export type StructuredAgentSessionAdoptionOwnership = { sessionId: string - provider: 'claude' | 'codex' + /** Any registered agent's chat may hold the conversation, though only Claude and Codex adopt. */ + provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease } diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 3a9e47fe6c29..d9a1d0268ab2 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -133,7 +133,10 @@ async function install(): Promise<{ .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) return { database, - store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), + store: AgentSessionRecordStore.open({ + journalDatabase: database, + hostId: 'local' + }), reports } } diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 1b1fe6e1b3b2..bc691038a6b1 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -118,7 +118,10 @@ export function loadAgentSessionStoreRows( lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } }) } else { - state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), raw: value }) + state.unreadableRecords.set(sessionId, { + reason: unreadableRecordReason(value), + raw: value + }) } } for (const row of db diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index e2e2da7e1fec..eed772bcf302 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -87,16 +87,14 @@ export class AgentSessionRecordStore { readonly hostId: string ) {} - /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is. */ + /** Reads every structurally valid row, independently of which agents this host can start. */ static open(args: { journalDatabase: JournalHostDatabase hostId: string }): AgentSessionRecordStore { - const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) - return new AgentSessionRecordStore( - new AgentSessionStoreTransactions(args.journalDatabase, loaded), - args.hostId - ) + const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows) + return new AgentSessionRecordStore(transactions, args.hostId) } private get state(): AgentSessionStoreState { diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index d70fde57af30..bb3c93a6a989 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -270,6 +270,24 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'third' }), marker()]) }) + it('forgets every record of any state except the ones kept, without a fence', async () => { + await capsule.record( + [marker(), marker({ sessionId: 'second' }), marker({ sessionId: 'kept' })], + NOW + ) + await fileFailure() + expect(await capsule.listFailed(NOW)).toHaveLength(1) + await capsule.beginResume(['second'], 'operation-b', NOW) + + const keep = (stored: { sessionId: string }) => stored.sessionId === 'kept' + expect(await capsule.dismiss('all', NOW, keep)).toBe(2) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'kept' })]) + expect(await capsule.listFailed(NOW)).toEqual([]) + // Unlike clearAll, a later teardown of a dismissed chat may offer it again. + await capsule.record([marker()], NOW) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'kept' }), marker()]) + }) + // A failure record has no expiry either; it ends only with the user's own actions. it('keeps a months-old failure on record', async () => { await capsule.record([marker()], NOW) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 54b579a1caff..ba34c82fc57c 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -201,21 +201,21 @@ export class AgentSessionRecoveryCapsule { }) } - /** Forgets the named sessions whatever their state. Unlike `clearAll`, this is not a fence: a - * later teardown of the same chat may record a fresh offer. */ + /** Forgets the named sessions, or every session, whatever their state. Unlike `clearAll`, this + * is not a fence: a later teardown of the same chat may record a fresh offer. */ dismiss( - sessionIds: readonly string[], + sessionIds: readonly string[] | 'all', now: number, /** A record this answers true for stays: read against the stored marker, under the lock. */ keep: (marker: AgentSessionResumeMarker) => boolean = () => false ): Promise { return withFileTransactionLock(this.filePath, async () => { - const named = new Set(sessionIds) + const named = sessionIds === 'all' ? null : new Set(sessionIds) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] - .filter((record) => named.has(record.marker.sessionId) && !keep(record.marker)) + .filter(({ marker }) => (named?.has(marker.sessionId) ?? true) && !keep(marker)) .map((record) => record.marker.sessionId) ) if (dismissed.size > 0) { diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index db5707d3b4d8..d36d87b0e993 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -38,7 +38,7 @@ import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { agentSessionProviderHandleRoot, - type AgentSessionHandleProvider, + type StructuredAgentId, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { @@ -51,7 +51,7 @@ import { agentSessionRecordIdentityFields } from './agent-session-record-foundin export type AgentSessionReserveRequest = { sessionId: string location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider + provider: StructuredAgentId accountHome: AgentSessionAccountHome /** Arguments pinned on first reservation so owner replacement repeats the same launch. */ launchArgs?: AgentSessionLaunchArgs diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index 6d2f4ddf6b2e..d3e7ea1c038d 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -15,7 +15,7 @@ import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-t import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' import type { PersistedAgentSessionTab } from './agent-session-tab-table' -/** A record row a load keeps in `records` rather than quarantining. */ +/** Valid stored identity, independent of provider availability. */ export function isReadableAgentSessionStoreRecord( sessionId: string, value: unknown 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 cb360070191e..e51f2c26ae22 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 @@ -1,8 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' -import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' -import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { @@ -13,44 +11,91 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' -import { - resolveStructuredClaudeAccountHomePath, - resolveStructuredCodexAccountHomePath -} from './structured-agent-account-home' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' 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' +import { + isAgentSessionHandleProvider, + type StructuredAgentId +} from '../../shared/agent-session-provider-handle' +import { agentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: AgentSessionHandleProvider + agent: StructuredAgentId ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) return resolveStructuredAgentSessionCreateSupport({ agent, location, - adapterSupportsCreate: - agent === 'claude' - ? supportsClaudeStructuredLocation(location) - : supportsCodexStructuredLocation(location), + adapterSupportsCreate: await this.structuredAgentSupportsLocation(agent, location), getSettings: () => this.requireStore().getSettings() }) } + /** The agent's own location rule, from its registration: answered without installing the host, + * and false for an agent this runtime does not register. */ + protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { + return structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false + } + + /** Where a launch of `agent` finds its account, resolved on this host by the agent's own + * registration; null for an agent this runtime does not register, whose create is refused. */ + protected structuredAgentAccountHomePathResolver( + agent: StructuredAgentId, + worktree: string, + purpose: 'launch' | 'read' + ) { + const registration = structuredAgentRuntimeRegistration(agent) + if (!registration) { + return null + } + const services = { + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), + prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, + readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, + workspaceTrustSettings: () => this.requireStore().getSettings() + } + return async ({ launchEnv, location }) => + registration.resolveAccountHomePath( + { + launchEnv, + location: location ?? null, + purpose, + workspacePath: + purpose === 'launch' + ? async () => (await this.resolveRuntimeFileTarget(worktree)).worktree.path + : null + }, + services + ) + } + + /** The definition this runtime registers for `agent`: what its account home pins. Read from the + * registration list, so neither create nor a catalog read installs the host to learn it. */ + protected requireRegisteredStructuredAgent(agent: StructuredAgentId) { + const definition = structuredAgentRuntimeRegistration(agent)?.definition + if (!definition) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return definition + } + /** 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: AgentSessionHandleProvider + agent: StructuredAgentId ): Record | undefined { return resolveStructuredLaunchSeedOptions( this.requireStore().getSettings().nativeChatSessionOptions, @@ -98,30 +143,21 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } }): Promise { - if (input.agent === 'claude') { - return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location.wslDistro, - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) - }) - ) - } - return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv }) => { - await applyStructuredCodexWorkspaceTrust({ - workspacePath: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path, - launchEnv, - settings: this.requireStore().getSettings() - }) - return resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.prepareCodexStructuredLaunchFn + const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( + input.agent, + input.worktree, + 'launch' + ) + if (!resolveAccountHomePath) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' }) - }) + } + return this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) } /** @@ -130,38 +166,27 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR * Same resolver as the create intent above — never a second copy. */ async resolveStructuredAgentAccountHome( - agent: AgentSessionHandleProvider + agent: StructuredAgentId ): Promise { + const resolvePath = this.structuredAgentAccountHomePathResolver(agent, '', 'read') + if (!resolvePath) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + const definition = this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) - if (agent === 'claude') { - return agentSessionAccountHome( - agent, - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: null, - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) - }) - ) - } - 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. - await resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn - }) - ) + return agentSessionAccountHome(definition, await resolvePath({ launchEnv, location: null })) } protected async resolveStructuredAgentSessionIntent( input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } }, @@ -176,7 +201,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) => string | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) - if (!support.supported) { + // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have + // importers for. + if (!support.supported || (input.resumeFrom && !isAgentSessionHandleProvider(input.agent))) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) @@ -185,6 +212,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) const options = this.structuredAgentSessionLaunchSeedOptions(input.agent) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) + const definition = this.requireRegisteredStructuredAgent(input.agent) const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, @@ -221,7 +249,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR provider: input.agent, agent: input.agent, accountHome: agentSessionAccountHome( - input.agent, + definition, adoption ? adoption.accountHomePath : selectedAccountHomePath ), ...(options ? { options } : {}), @@ -231,14 +259,11 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR // `providerHandle` alone must not: `agentSession.ensure` already passes one today // without adopting anything. adopt: { - providerHandle: - input.agent === 'claude' - ? { - kind: 'claude' as const, - sessionId: input.resumeFrom.providerSessionId, - leafUuid: null - } - : { kind: 'codex' as const, threadId: input.resumeFrom.providerSessionId }, + providerHandle: agentSessionWireProviderHandle({ + transport: definition.handleTransport, + agent: definition.agent, + nativeId: input.resumeFrom.providerSessionId + }), transcriptPath: adoption.transcriptPath } } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 61c2e0b3605f..c99a62e1d196 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -27,6 +27,7 @@ import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { /** Projects only: a replacement's chat already has its tab in the store, which the /clear commit @@ -73,10 +74,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + // Every session here is of an agent this host registered: its store holds no other agent's records. const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { - if (session.agent !== 'codex' && session.agent !== 'claude') { - return [] - } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) @@ -111,7 +110,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'claude' | 'codex' + agent: StructuredAgentId activate: boolean notify?: boolean replacesSessionId?: string @@ -139,7 +138,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu projectStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'claude' | 'codex' + agent: StructuredAgentId activate: boolean notify?: boolean replacesSessionId?: string diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts new file mode 100644 index 000000000000..99e4670ead35 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -0,0 +1,77 @@ +// createSupport, create and the model catalog's account read ask the runtime's one registration +// list where an agent runs and which account it pins, before any host exists. An agent the list does +// not hold is answered no without installing the host. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' + +afterEach(() => vi.restoreAllMocks()) + +const LOCAL = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const +} + +function runtimeAt(location = LOCAL) { + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these reads consume only getSettings from the store. + { getSettings: () => ({ agentDefaultEnv: {} }) } as never + ) + const installHost = vi.fn(async () => { + throw new Error('the host must not be installed to answer this') + }) + Object.assign(runtime, { + resolveStructuredAgentSessionLocation: vi.fn(async () => location), + ensureStructuredAgentSessionHost: installHost + }) + return { runtime, installHost } +} + +describe('the structured agent registration list', () => { + it("answers createSupport from the agent's own location rule", async () => { + const codex = structuredAgentRuntimeRegistration('codex')! + const { runtime, installHost } = runtimeAt() + expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex')).toEqual( + { supported: true } + ) + + vi.spyOn(codex, 'supportsLocation').mockReturnValue(false) + expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex')).toEqual( + { supported: false, reason: 'agent' } + ) + expect(codex.supportsLocation).toHaveBeenCalledWith(LOCAL) + expect(installHost).not.toHaveBeenCalled() + }) + + it('refuses an agent it does not hold without installing the host', async () => { + const { runtime, installHost } = runtimeAt() + + expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'grok')).toEqual({ + supported: false, + reason: 'agent' + }) + await expect(runtime.resolveStructuredAgentAccountHome('grok')).rejects.toMatchObject({ + message: 'structured_agent_session_unsupported' + }) + expect(installHost).not.toHaveBeenCalled() + }) + + it("resolves an account home through the agent's registration, with the read purpose", async () => { + const claude = structuredAgentRuntimeRegistration('claude')! + vi.spyOn(claude, 'resolveAccountHomePath').mockResolvedValue('/accounts/claude') + const { runtime, installHost } = runtimeAt() + + expect(await runtime.resolveStructuredAgentAccountHome('claude')).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude' + }) + expect(claude.resolveAccountHomePath).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'read', location: null, workspacePath: null }), + expect.anything() + ) + expect(installHost).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 3376d9bf994c..267b201edf90 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -46,6 +46,7 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' import { AGENT_LAUNCH_METHODS } from './agent-launch' @@ -63,6 +64,7 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_AGENTS_METHODS, ...AGENT_LAUNCH_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 5686d8135592..995c8281d870 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,7 +1,5 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -10,7 +8,10 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { supportsStructuredAgentSessions } from './structured-agent-session-policy' +import { + clientRendersStructuredAgent, + supportsStructuredAgentSessions +} from './structured-agent-session-policy' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot @@ -22,13 +23,9 @@ function clientCanRenderStructuredAgentSessionTab( tab: RuntimeMobileSessionAgentTab, clientCapabilities: readonly RuntimeCapability[] | undefined ): boolean { - if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { - return false - } - return ( - tab.agent === 'codex' || - clientCapabilities.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - ) + // Every shipped client reads only Claude and Codex as chats; any other agent's tab would list + // with an empty pane, so it waits for a client that says it renders the host's agents. + return clientRendersStructuredAgent(clientCapabilities, tab.agent) } function resolveMobileStructuredChatFallbackTitle( @@ -65,15 +62,14 @@ export function projectSessionTabAgentStatus true) - // Why: a paired client renders only codex structured tabs unless it says otherwise - // (mobile's resolveMobileNativeChat returns null for every other agent), so an - // ungated row would list and select into a pane that shows neither chat nor terminal. - if ( - structuredVisible && - clientKind !== undefined && - !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) - ) { - projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + // Why: a paired client renders only the agents it says it does (mobile's + // resolveMobileNativeChat returns null for every other agent), so an ungated row would + // list and select into a pane that shows neither chat nor terminal. + if (structuredVisible && clientKind !== undefined) { + projected = projectAgentSessionTabsOut( + projected, + (tab) => !clientCanRenderStructuredAgentSessionTab(tab, clientCapabilities) + ) } } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. diff --git a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts new file mode 100644 index 000000000000..bc446197ca0f --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import { + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE, + assertAgentSessionTabDestructiveMutationSupported, + projectSessionTabAgentStatus +} from './session-tab-agent-status-projection' + +const TODAYS_CLIENT = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] +const REGISTERED_AGENTS_CLIENT = [ + ...TODAYS_CLIENT, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] + +function chatTab(agent: string, isActive: boolean) { + return { + type: 'agent-session' as const, + id: `agent-session:${agent}-session`, + title: `${agent} chat`, + sessionId: `${agent}-session`, + agent, + isActive + } +} + +function snapshot(): RuntimeMobileSessionTabsSnapshot { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-a', + activeTabId: 'agent-session:grok-session', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-a', + activeTabId: 'agent-session:grok-session', + tabOrder: [ + 'agent-session:claude-session', + 'agent-session:codex-session', + 'agent-session:grok-session' + ] + } + ], + tabs: [chatTab('claude', false), chatTab('codex', false), chatTab('grok', true)] + } +} + +describe('a tab of an agent beyond Claude and Codex', () => { + it("is withheld from a paired client that does not render the host's agents", () => { + const projected = projectSessionTabAgentStatus(snapshot(), 'runtime', TODAYS_CLIENT) + expect(projected.tabs.map((tab) => tab.id)).toEqual([ + 'agent-session:claude-session', + 'agent-session:codex-session' + ]) + expect(projected.tabGroups?.[0]?.tabOrder).not.toContain('agent-session:grok-session') + expect(projected.activeTabId).not.toBe('agent-session:grok-session') + }) + + it('reaches a client that advertises the registered-agents capability unchanged', () => { + const payload = snapshot() + expect(projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT)).toBe(payload) + }) + + it('stays listed on a phone under the title that names the fix', () => { + const projected = projectSessionTabAgentStatus(snapshot(), 'mobile', TODAYS_CLIENT) + expect(projected.tabs.map((tab) => tab.title)).toEqual([ + 'claude chat', + 'codex chat', + STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE + ]) + }) + + it('cannot be closed by a client that cannot render it', () => { + expect(() => + assertAgentSessionTabDestructiveMutationSupported( + snapshot(), + 'agent-session:grok-session', + 'runtime', + TODAYS_CLIENT + ) + ).toThrow('structured_agent_session_unsupported') + expect(() => + assertAgentSessionTabDestructiveMutationSupported( + snapshot(), + 'agent-session:grok-session', + 'runtime', + REGISTERED_AGENTS_CLIENT + ) + ).not.toThrow() + }) + + it('leaves Claude and Codex tabs as they were for every client', () => { + const payload = { ...snapshot(), tabs: snapshot().tabs.slice(0, 2) } + expect(projectSessionTabAgentStatus(payload, 'runtime', TODAYS_CLIENT)).toBe(payload) + expect( + projectSessionTabAgentStatus(payload, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex-session']) + }) +}) 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 f1faf8e1b388..db540d3bbd73 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,7 +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' +import { claudeAndCodexAgents } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -188,7 +188,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexAgents(sessionAdapter), logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts new file mode 100644 index 000000000000..6d03767d79f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -0,0 +1,23 @@ +// `agentSession.agents` — the structured agents this host registered, with each agent's declared +// capability record, so a client can show what an agent supports before a session of it exists. +// +// Additive and negotiated: a client calls it only once the host advertises +// `agent-session.structured.registered-agents.v1`; without it, a client knows Claude and Codex only. + +import type { AgentSessionAgentsResult } from '../../../../shared/agent-session-registered-agents' +import { AGENT_SESSION_AGENTS_METHOD } from '../../../../shared/agent-session-registered-agents' +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' +import { AgentsParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ + defineMethod({ + name: AGENT_SESSION_AGENTS_METHOD, + params: AgentsParams, + handler: async (_params, ctx): Promise => ({ + agents: (await requireInstalledStructuredHost(ctx)) + .agentDefinitions() + .map(({ agent, capabilities }) => ({ agent, capabilities: { ...capabilities } })) + }) + }) +] 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 46419629347b..1e5bb7e6bc37 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 @@ -22,7 +22,6 @@ import { restTestSend, type RestTestRig } from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' -import * as providerSupport from '../../../native-chat/agent-session-wire/structured-agent-session-provider-support' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -221,22 +220,6 @@ describe('the accessor', () => { } } }) - - await restingChat() - vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(false) - const [unsupported] = await call('agentSession.history', { - sessionId: SESSION, - direction: 'tail' - }) - expect(unsupported).toMatchObject({ - ok: false, - error: { - // Not a passthrough code: released clients match the message, as before. - code: 'runtime_error', - message: 'structured_agent_session_unsupported', - data: { refusal: { details: { reason: 'hostUnsupported' } } } - } - }) }) it('refuses a read whose journal will not open with the classified reason, never the storage text', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 1d0899a014df..af82880543fb 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -27,6 +27,7 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { StructuredAgentSessionResumeSource } from '../../../../shared/structured-agent-session-create' import type { OrcaRuntimeService } from '../../orca-runtime' +import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle' import { resolveUncommittedStructuredCreate, type StructuredCreateRefused @@ -36,7 +37,7 @@ export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost attachParams: AgentSessionAttachParams /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ - tab: { workspaceId: string; agent: 'claude' | 'codex' } | null + tab: { workspaceId: string; agent: StructuredAgentId } | null } /** @@ -73,7 +74,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { ensureHost: () => Promise envelope: AgentSessionMutationEnvelope worktree: string - agent: 'claude' | 'codex' + agent: StructuredAgentId caller: StructuredAgentSessionCaller resumeFrom?: StructuredAgentSessionResumeSource /** Replaces the seed options the host resolves from settings. Orchestration passes the @@ -109,13 +110,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { // must replay rather than conflict. ...(args.options ? { options: args.options } : {}), ...(args.tabId ? { surfaceTabId: args.tabId } : {}), - provider: resolved.provider as 'claude' | 'codex', - agent: resolved.agent as 'claude' | 'codex', + provider: resolved.provider, + agent: resolved.agent, envelope: { ...args.envelope, payloadFingerprint: hostFingerprint } }, tab: { workspaceId: resolved.location.workspaceId, - agent: resolved.agent as 'claude' | 'codex' + agent: resolved.agent } } } @@ -167,7 +168,7 @@ export async function createStructuredAgentSessionForWorktree(args: { caller: StructuredAgentSessionCaller envelope: AgentSessionMutationEnvelope worktree: string - agent: 'claude' | 'codex' + agent: StructuredAgentId activate: boolean options?: Readonly> tabId?: string diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index cc03bfd21a5c..0892f35d7976 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,6 +1,9 @@ import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' @@ -21,6 +24,39 @@ export function supportsStructuredAgentSessions( ) } +/** Whether a remote client renders `agent`'s chat: the one rule for every surface that withholds + * an agent's rows (tabs, restart offers). Codex needs structured support; Claude also its own + * capability; any other agent a client that renders the host's registered agents. */ +export function clientRendersStructuredAgent( + clientCapabilities: readonly RuntimeCapability[] | undefined, + agent: string +): boolean { + if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + return false + } + if (agent === 'codex') { + return true + } + return clientCapabilities.includes( + agent === 'claude' + ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + : STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} + +/** The agents this client reads rows of, among those registered or saved here; undefined when it + * reads every one, so an action for it is exactly the unscoped one (one fence for every offer). */ +export function structuredAgentsReadBy( + context: Pick, + agents: readonly string[] +): ((agent: string) => boolean) | undefined { + if (context.clientKind === undefined) { + return undefined + } + const reads = (agent: string) => clientRendersStructuredAgent(context.clientCapabilities, agent) + return agents.every(reads) ? undefined : reads +} + /** * COMPAT(released phones): a remote client that does not pick each launch's mode itself reads * `agentSession.createSupport` as "should this launch be a chat", which the host's setting diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts new file mode 100644 index 000000000000..9937a3eb3d89 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -0,0 +1,100 @@ +// "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches +// with: a paired runtime client without the registered-agents capability. On a host whose agents it +// all shows, the dismissal is the unscoped, fenced one; one that cannot show some agent leaves that +// agent's offers alone. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + AgentSessionRecoveryCapsule +} from '../../agent-session-recovery-capsule' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentRegistry } from '../../../native-chat/agent-session-wire/structured-agent-registry' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { interruptedRestart } from '../../../native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness' +import { HOST_TEST_NOW as NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { call, clearStructuredHostStub } from './structured-agent-session-rpc.test-fixture' + +afterEach(() => { + clearStructuredHostStub() + vi.restoreAllMocks() +}) + +// Exactly what `runtime:call` in src/main/ipc/runtime.ts dispatches the local renderer with. +const DESKTOP = { + clientKind: 'runtime' as const, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a registry reads only the methods a declaration needs; these declare none. +const NO_METHODS = {} as StructuredAgentSessionAdapter +const declaring = (definition: typeof CLAUDE_STRUCTURED_AGENT) => ({ + definition: { + ...definition, + capabilities: { ...definition.capabilities, compact: false, threadGoal: false, rewind: false } + }, + adapter: NO_METHODS +}) + +/** This build's two agents plus one the desktop does not render yet. */ +function withPilot(): StructuredAgentRegistry { + return new StructuredAgentRegistry([ + declaring(CLAUDE_STRUCTURED_AGENT), + declaring(CODEX_STRUCTURED_AGENT), + declaring({ ...CODEX_STRUCTURED_AGENT, agent: 'grok', accountHomeVariable: 'GROK_HOME' }) + ]) +} + +async function storedFence(root: string) { + const { dismissedAt } = JSON.parse( + await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + return dismissedAt ? { dismissedAt } : {} +} + +async function dismissAllFromDesktop() { + return call('agentSession.restartResumableDismiss', {}, DESKTOP) +} + +it('fences a dismissed offer against a late teardown write when the desktop sees every agent', async () => { + const { host, root, marker } = await interruptedRestart() + setStructuredAgentSessionHost(host) + const capsule = new AgentSessionRecoveryCapsule(root) + + expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + // A teardown writer that captured this chat before the dismissal publishes late. + await capsule.record([marker!], NOW) + + expect(await capsule.list(NOW)).toEqual([]) + // The unscoped dismissal every caller took before agents beyond Claude and Codex: one fence for all. + expect(await storedFence(root)).toEqual({ dismissedAt: NOW + 1 }) + expect(await call('agentSession.restartResumable', {}, DESKTOP)).toMatchObject({ + ok: true, + result: { sessions: [] } + }) +}) + +it('leaves offers it was not shown when the host runs an agent the desktop cannot show', async () => { + const { host, root, marker } = await interruptedRestart( + undefined, + undefined, + undefined, + withPilot() + ) + setStructuredAgentSessionHost(host) + const capsule = new AgentSessionRecoveryCapsule(root) + // An offer whose chat this launch cannot read names no agent the desktop was shown. + const unreadable = { ...marker!, sessionId: 'unreadable-session' } + await capsule.record([unreadable], NOW) + + expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + + expect(await capsule.list(NOW)).toEqual([unreadable]) + // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. + expect(await storedFence(root)).toEqual({}) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts new file mode 100644 index 000000000000..aacdcdc7f74e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -0,0 +1,149 @@ +// The restart-offer methods act for the calling client: a paired client too old to show an agent +// is never listed, resumed, dismissed, or answered that agent's offers. The host stub filters by the +// audience it is handed, as the real host does (structured-agent-session-restart-audience.test.ts). + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + restartRowsFor, + type StructuredAgentSessionRestartAudience +} from '../../../native-chat/agent-session-wire/structured-agent-session-restart-resume-set' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { + call, + clearStructuredHostStub, + hostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +afterEach(clearStructuredHostStub) + +const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } +const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } + +// What the desktop renderer really dispatches with: every client that calls these methods +// advertises Claude support. +const OLD_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} +const NEW_CLIENT = { + ...OLD_CLIENT, + clientCapabilities: [ + ...OLD_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] +} + +/** A host holding a Claude and a Grok offer, plus a recorded Grok failure. */ +function installRestartHost() { + let offers = [CLAUDE_OFFER, GROK_OFFER] + const failures = [{ ...GROK_OFFER, reason: 'agent_session_resume_refused' }] + const list = async (audience?: StructuredAgentSessionRestartAudience) => + restartRowsFor(offers, audience) + const listFailures = async (audience?: StructuredAgentSessionRestartAudience) => + restartRowsFor(failures, audience) + const restartResume = { + list: vi.fn(list), + listFailures: vi.fn(listFailures), + dismiss: vi.fn( + async (sessionIds?: readonly string[], audience?: StructuredAgentSessionRestartAudience) => { + const gone = restartRowsFor(offers, audience).filter( + (offer) => sessionIds?.includes(offer.sessionId) ?? true + ) + offers = offers.filter((offer) => !gone.includes(offer)) + return gone.length + } + ), + continueAfterRestart: vi.fn( + async ( + _sessionIds: readonly string[] | undefined, + _owner: string, + audience?: StructuredAgentSessionRestartAudience + ) => ({ + resumed: [], + continued: [], + sessions: await list(audience), + failed: await listFailures(audience) + }) + ) + } + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + restartResume, + knownAgentIds: () => ['claude', 'codex', 'grok'] + }) + ) + return { restartResume, offers: () => offers } +} + +describe("a paired client too old to show the host's other agents", () => { + it('lists only the offers it can show', async () => { + installRestartHost() + expect(await call('agentSession.restartResumable', {}, OLD_CLIENT)).toMatchObject({ + ok: true, + result: { sessions: [CLAUDE_OFFER], failed: [] } + }) + }) + + it('dismisses only the offers it was shown when it dismisses all', async () => { + const host = installRestartHost() + expect(await call('agentSession.restartResumableDismiss', {}, OLD_CLIENT)).toMatchObject({ + ok: true, + result: { dismissed: 1, sessions: [], failed: [] } + }) + expect(host.offers()).toEqual([GROK_OFFER]) + }) + + it('cannot dismiss an offer it was not shown by naming it', async () => { + const host = installRestartHost() + expect( + await call( + 'agentSession.restartResumableDismiss', + { sessionIds: [GROK_OFFER.sessionId] }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true, result: { dismissed: 0, sessions: [CLAUDE_OFFER], failed: [] } }) + expect(host.offers()).toEqual([CLAUDE_OFFER, GROK_OFFER]) + }) + + it('hands the host its audience when it continues all, so hidden offers are not run', async () => { + const host = installRestartHost() + await call('agentSession.restartContinue', {}, OLD_CLIENT) + const audience = host.restartResume.continueAfterRestart.mock.lastCall?.[2] + expect(host.restartResume.continueAfterRestart.mock.lastCall?.[0]).toBeUndefined() + expect(audience?.('claude')).toBe(true) + expect(audience?.('codex')).toBe(true) + expect(audience?.('grok')).toBe(false) + }) + + it('is not answered the hidden offers or failures after a named continuation', async () => { + installRestartHost() + expect( + await call( + 'agentSession.restartContinue', + { sessionIds: [CLAUDE_OFFER.sessionId] }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true, result: { sessions: [CLAUDE_OFFER], failed: [] } }) + }) +}) + +describe.each([ + ["a paired client that shows the host's agents", NEW_CLIENT], + ["the host's own process", undefined] +])('%s', (_label, client) => { + it('acts on every offer', async () => { + const host = installRestartHost() + expect(await call('agentSession.restartResumable', {}, client)).toMatchObject({ + ok: true, + result: { sessions: [CLAUDE_OFFER, GROK_OFFER], failed: [{ agent: 'grok' }] } + }) + await call('agentSession.restartContinue', {}, client) + expect(host.restartResume.continueAfterRestart.mock.lastCall?.[2]).toBeUndefined() + await call('agentSession.restartResumableDismiss', {}, client) + expect(host.restartResume.dismiss.mock.lastCall?.[1]).toBeUndefined() + expect(host.offers()).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index a48f897a005e..1d1ddfcc5983 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -3,7 +3,7 @@ // Each method reaches for records on disk this process may not have opened yet, so each builds the // host the way hold and reveal do. Listing takes nothing live and spends no live offer; acting goes // through the host's single resume path, which re-derives eligibility rather than trusting the ids -// it is given. +// it is given. Every method acts only on offers for agents the calling client can show. import { defineMethod } from '../core' import { @@ -12,6 +12,7 @@ import { structuredCallerFor } from './structured-agent-session-gate' import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' +import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ @@ -20,10 +21,11 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) + const audience = structuredAgentsReadBy(ctx, host.knownAgentIds()) return { - sessions: await host.restartResume.list(), + sessions: await host.restartResume.list(audience), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. - failed: await host.restartResume.listFailures() + failed: await host.restartResume.listFailures(audience) } } }), @@ -35,16 +37,18 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - const dismissed = await host.restartResume.dismiss(params.sessionIds) + // Offers this client was never shown stay for a client that can show them. + const audience = structuredAgentsReadBy(ctx, host.knownAgentIds()) + const dismissed = await host.restartResume.dismiss(params.sessionIds, audience) if (params.sessionIds === undefined) { - // clearAll is the authoritative mutation: it removes pending and in-flight records, so a - // second read would only add a new failure point after the user's explicit dismissal. + // The dismissal removed every pending and in-flight record this client sees, so a second + // read would only add a new failure point after the user's explicit dismissal. return { dismissed, sessions: [], failed: [] } } return { dismissed, - sessions: await host.restartResume.list(), - failed: await host.restartResume.listFailures() + sessions: await host.restartResume.list(audience), + failed: await host.restartResume.listFailures(audience) } } }), @@ -59,7 +63,8 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ const host = requireStructuredHost(ctx) return host.restartResume.continueAfterRestart( params.sessionIds, - structuredCallerFor(ctx).callerKey + structuredCallerFor(ctx).callerKey, + structuredAgentsReadBy(ctx, host.knownAgentIds()) ) } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts new file mode 100644 index 000000000000..c8154f530bd9 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts @@ -0,0 +1,158 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import { agentSessionProviderHandleRoot } from '../../../../shared/agent-session-provider-handle' +import type { AgentSessionResumeMarker } from '../../../../shared/agent-session-resume-marker' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} 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' +import { StructuredAgentRegistry } from '../../../native-chat/agent-session-wire/structured-agent-registry' +import { StructuredAgentSessionAdapterRouter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../agent-session-record-store-test-harness' +import { call, clearStructuredHostStub } from './structured-agent-session-rpc.test-fixture' + +const NOW = 1_800_000_000_000 +const FAILED = 'saved-grok-failure' +const PENDING = 'saved-grok-offer' +const OLD_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} +const NEW_CLIENT = { + ...OLD_CLIENT, + clientCapabilities: [ + ...OLD_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] +} + +let root: string | null = null +let host: StructuredAgentSessionHost | null = null + +afterEach(async () => { + clearStructuredHostStub() + await host?.flushAllStreamedEvents() + host = null + if (root) { + closeTestJournalHostDatabase(root) + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function savedGrokRecord(sessionId: string): AgentSessionRecord { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { + ...record, + provider: 'grok', + accountHome: { variable: 'GROK_HOME', path: join(root!, 'grok-home') }, + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { transport: 'acp', agent: 'grok', nativeId: `native-${sessionId}` } + })) + } +} + +function marker(record: AgentSessionRecord): AgentSessionResumeMarker { + return { + sessionId: record.sessionId, + work: { kind: 'turn', id: 'interrupted-turn' }, + trigger: 'quit', + recordedAt: NOW, + latestUserItemId: null, + providerHandleRoot: agentSessionProviderHandleRoot(record.providerHandleChain[0]!.handle), + teardownId: 'before-registration-removal' + } +} + +it.each(['empty', 'claude-codex'] as const)( + 'keeps saved Grok recovery rows outside an old client audience with %s registrations', + async (registrations) => { + root = await mkdtemp(join(tmpdir(), 'orca-unregistered-restart-')) + const records = [savedGrokRecord(FAILED), savedGrokRecord(PENDING)] + await seedTestAgentSessionRecordStore(root, { records }) + const store = await openTestAgentSessionRecordStore(root) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW }) + const capsule = new AgentSessionRecoveryCapsule(root) + await capsule.record(records.map(marker), NOW) + await capsule.beginResume([FAILED], 'operation-a', NOW) + await capsule.failResume( + 'operation-a', + [ + { + sessionId: FAILED, + failedAt: NOW, + outcome: 'refused', + reason: 'unavailable', + latestPrompt: 'Continue', + latestUserItemId: null + } + ], + NOW + ) + const agents = + registrations === 'empty' ? new StructuredAgentRegistry([]) : claudeAndCodexDeclared() + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const acquire = vi.spyOn(router, 'acquire') + host = new StructuredAgentSessionHost({ + store, + agents, + adapter: router, + journalDatabase: openTestJournalHostDatabase(root), + recoveryCapsule: capsule, + claimKeyId: 'key-1', + probeOwner: async () => ({ outcome: 'pid-absent' }), + logger: createStructuredAgentSessionLogger(), + now: () => NOW + 1 + }) + setStructuredAgentSessionHost(host) + + expect(await call('agentSession.restartResumable', {}, OLD_CLIENT)).toMatchObject({ + ok: true, + result: { sessions: [], failed: [] } + }) + expect( + await call('agentSession.restartContinue', { sessionIds: [FAILED, PENDING] }, OLD_CLIENT) + ).toMatchObject({ + ok: true, + result: { resumed: [], continued: [], sessions: [], failed: [] } + }) + for (const params of [{ sessionIds: [FAILED, PENDING] }, {}]) { + expect(await call('agentSession.restartResumableDismiss', params, OLD_CLIENT)).toMatchObject({ + ok: true, + result: { dismissed: 0, sessions: [], failed: [] } + }) + expect(await capsule.list(NOW + 1)).toMatchObject([{ sessionId: PENDING }]) + expect(await capsule.listFailed(NOW + 1)).toMatchObject([{ marker: { sessionId: FAILED } }]) + } + expect(acquire).not.toHaveBeenCalled() + + expect(await call('agentSession.restartResumable', {}, NEW_CLIENT)).toMatchObject({ + ok: true, + result: { sessions: [], failed: [{ sessionId: FAILED, agent: 'grok', retryable: false }] } + }) + expect(await call('agentSession.restartResumableDismiss', {}, NEW_CLIENT)).toMatchObject({ + ok: true, + result: { dismissed: 1 } + }) + expect(await capsule.list(NOW + 1)).toEqual([]) + expect(await capsule.listFailed(NOW + 1)).toEqual([]) + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 5930fa1e80fa..973fcc5e65b4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -3,6 +3,7 @@ // Strict objects throughout: zod drops unknown keys, and a silently dropped key // is how a newer client's field becomes a different effect on an older host. export { + AgentsParams, AttachParams, CancelParams, ConversationCommandParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index e0d24119086c..f829560697d4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -175,7 +175,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }, envelope: params.envelope, worktree: params.worktree, - agent: params.agent as 'claude' | 'codex', + agent: params.agent, caller: callerFor(ctx), ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), ...(params.tabId ? { tabId: params.tabId } : {}) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index c7c79aebe614..0b537dd04893 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -12,6 +12,8 @@ import type { ClaudeStructuredLaunchResolverDeps } from '../claude/claude-struct import type { CodexStructuredLaunchResolverDeps } from '../codex/codex-structured-launch-resolution' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { agentDrivesSession } from '../native-chat/agent-session-wire/structured-agent-session-provider-support' // The store is process-global; hydrate it from disk at most once per process. let persistenceAttached = false @@ -44,6 +46,7 @@ export async function attachAgentModelCatalogPersistenceOnce( */ export async function modelCatalogHostDeps(input: { store: Pick + agents: Pick deps: Pick< StructuredAgentSessionRuntimeDeps, | 'stateDirectory' @@ -68,6 +71,7 @@ export async function modelCatalogHostDeps(input: { const modelCatalog = createAgentModelCatalogService({ store: agentModelCatalogStore, getRecord: (sessionId) => input.store.getRecord(sessionId) ?? undefined, + drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, workspaceMayOverrideDefaultModel, probes: { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts new file mode 100644 index 000000000000..5db941bb8d88 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -0,0 +1,190 @@ +// Each runtime registration owns its adapter, location support and account resolution at start. + +import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' +import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' +import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import type { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import type { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' +import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { + resolveStructuredClaudeAccountHomePath, + resolveStructuredCodexAccountHomePath, + type StructuredClaudeAccountHomeDeps, + type StructuredCodexAccountHomeDeps +} from './structured-agent-account-home' + +/** What an agent's adapter is built from: the open store and the runtime around it. */ +export type StructuredAgentAdapterContext = { + deps: StructuredAgentSessionRuntimeDeps + store: AgentSessionRecordStore + environment: ReturnType + /** Hands the host an exit or other lifecycle event the agent observed. */ + deliverLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void + followUps: ReturnType + /** Null until the host is built: the adapters are built first. */ + host: () => StructuredAgentSessionHost | null +} + +export type StructuredAgentRuntimeAdapter = StructuredAgentSessionAdapter & { + closeAll: () => Promise + /** Resolves once exits the agent observed have been published; absent when it publishes at once. */ + drainObservedExits?: () => Promise +} + +/** Which account home a chat of an agent pins, asked on the host that runs it. */ +export type StructuredAgentAccountHomeRequest = { + launchEnv: NodeJS.ProcessEnv + /** Where the chat runs; null for a read with no workspace (the model catalog). */ + location: AgentSessionExecutionLocation | null + /** A `read` has no side effects: it syncs no home, starts no bridge, clears no selection. */ + purpose: 'launch' | 'read' + /** The launch's workspace directory on this host; a read has none. */ + workspacePath: (() => Promise) | null +} + +/** What resolving an account may ask of the runtime around it. */ +export type StructuredAgentAccountHomeServices = { + getClaudeConfigDirectory: StructuredClaudeAccountHomeDeps['getClaudeConfigDirectory'] + /** Codex's home for a launch, prepared for it; and the same answer with no side effects. */ + prepareCodexLaunchHome: StructuredCodexAccountHomeDeps['resolveLaunchHome'] + readCodexLaunchHome: StructuredCodexAccountHomeDeps['resolveLaunchHome'] + workspaceTrustSettings: () => Parameters[0]['settings'] +} + +export type StructuredAgentRuntimeRegistration = { + definition: StructuredAgentDefinition + createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter + /** Whether this agent's chats can run at `location`; answered without building the host. */ + supportsLocation: (location: AgentSessionExecutionLocation) => boolean + /** The account home a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ + resolveAccountHomePath: ( + request: StructuredAgentAccountHomeRequest, + services: StructuredAgentAccountHomeServices + ) => Promise +} + +function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { + const { deps, store, followUps, host } = context + return new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: context.environment.resolveCodexEnvironment, + ...(deps.resolveCodexPermissionPolicy + ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } + : {}), + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + modelCatalog: agentModelCatalogStore, + onChildWorkEvidence: (sessionId, evidence) => + host()?.publishChildWorkEvidence(sessionId, evidence), + onDispatchSettledLate: followUps.onDispatchSettledLate, + onPrimaryThreadStoppedRunning: followUps.releaseUnansweredDispatches, + logger: deps.logger, + onEvent: (event) => { + // Every exit, expected or not: the host ends that child's record. + if (event.type === 'ended' && 'cause' in event) { + context.deliverLifecycle(event) + } + } + }) +} + +function createClaudeAdapter( + context: StructuredAgentAdapterContext +): StructuredAgentRuntimeAdapter { + const { deps, store, followUps, host } = context + return createStructuredClaudeRuntimeAdapter({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), + ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), + resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, + resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.resolveClaudePermissionMode + ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } + : {}), + ...(deps.getClaudeManagedAccountGateSettings + ? { + readClaudeManagedAccountGate: () => + readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) + } + : {}), + onLifecycleEvent: context.deliverLifecycle, + logger: deps.logger, + onChildWorkEvidence: (sessionId, evidence) => + host()?.publishChildWorkEvidence(sessionId, evidence), + onDispatchSettledLate: followUps.onDispatchSettledLate, + onSessionIdle: followUps.releaseUnansweredDispatches, + ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + modelCatalog: agentModelCatalogStore + }) +} + +async function resolveCodexAccountHomePath( + request: StructuredAgentAccountHomeRequest, + services: StructuredAgentAccountHomeServices +): Promise { + const { launchEnv, purpose, workspacePath } = request + if (purpose === 'launch' && workspacePath) { + await applyStructuredCodexWorkspaceTrust({ + workspacePath: await workspacePath(), + launchEnv, + settings: services.workspaceTrustSettings() + }) + } + return resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: + purpose === 'launch' ? services.prepareCodexLaunchHome : services.readCodexLaunchHome + }) +} + +export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = + [ + { + definition: CODEX_STRUCTURED_AGENT, + createAdapter: createCodexAdapter, + supportsLocation: (location) => supportsCodexStructuredLocation(location), + resolveAccountHomePath: resolveCodexAccountHomePath + }, + { + definition: CLAUDE_STRUCTURED_AGENT, + createAdapter: createClaudeAdapter, + supportsLocation: supportsClaudeStructuredLocation, + resolveAccountHomePath: async ({ launchEnv, location }, services) => + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: services.getClaudeConfigDirectory + }) + } + ] + +/** The registration of `agent`; null for an agent this runtime does not drive. */ +export function structuredAgentRuntimeRegistration( + agent: string +): StructuredAgentRuntimeRegistration | null { + return ( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.find(({ definition }) => definition.agent === agent) ?? + null + ) +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 9fe0506658ad..88544a6c8067 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -20,15 +20,9 @@ import { type InstalledRuntime } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' -import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' -import { - CodexStructuredSessionAdapter, - type CodexStructuredSessionAdapterDeps -} from '../codex/codex-structured-session-adapter' +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 @@ -36,10 +30,7 @@ import { 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, - type ClaudeManagedAccountGateSettings -} from '../native-chat/claude-structured-managed-account-support' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' @@ -53,7 +44,10 @@ import { import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' -import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + type StructuredAgentAdapterContext +} from './structured-agent-runtime-registrations' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' @@ -244,75 +238,38 @@ async function installOnJournal( journalDatabase: JournalHostDatabase ): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) - const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers - const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId }) + const store = AgentSessionRecordStore.open({ + journalDatabase, + hostId: deps.hostId + }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), logger: deps.logger, - // Claude publishes an observed exit only after its close ladder and transcript write; Codex - // publishes inside its own exit callback and needs nothing. - drainObservedExits: () => claude.drainObservedExits() - }) - const { onDispatchSettledLate, releaseUnansweredDispatches } = - createStructuredAgentSessionDispatchFollowUps({ host: () => host, logger: deps.logger }) - const codex = new CodexStructuredSessionAdapter({ - resolveLaunch: createCodexStructuredLaunchResolver({ - store, - resolveWorkspacePath: deps.resolveWorkspacePath, - resolveEnvironment: resolveCodexEnvironment, - ...(deps.resolveCodexPermissionPolicy - ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } - : {}), - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore, - onChildWorkEvidence: (sessionId, evidence) => - host?.publishChildWorkEvidence(sessionId, evidence), - onDispatchSettledLate, - onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, - logger: deps.logger, - onEvent: (event) => { - // Every exit, expected or not: the host ends that child's record. - if (event.type === 'ended' && 'cause' in event) { - lifecycle.deliver(event) - } + // An agent that publishes an observed exit only after its own close work drains it here. + drainObservedExits: async () => { + await Promise.all( + registrations.map(({ adapter }) => adapter.drainObservedExits?.() ?? Promise.resolve()) + ) } }) - const claude = createStructuredClaudeRuntimeAdapter({ + const context: StructuredAgentAdapterContext = { + deps, store, - resolveWorkspacePath: deps.resolveWorkspacePath, - ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), - ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), - resolveClaudeInheritedEnv, - resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudePermissionMode - ? { resolveClaudePermissionMode: deps.resolveClaudePermissionMode } - : {}), - ...(deps.getClaudeManagedAccountGateSettings - ? { - readClaudeManagedAccountGate: () => - readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) - } - : {}), - onLifecycleEvent: (event) => lifecycle.deliver(event), - logger: deps.logger, - onChildWorkEvidence: (sessionId, evidence) => - host?.publishChildWorkEvidence(sessionId, evidence), - onDispatchSettledLate, - onSessionIdle: releaseUnansweredDispatches, - ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore - }) - const agents = new StructuredAgentRegistry([ - { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } - ]) + environment: envResolvers, + deliverLifecycle: lifecycle.deliver, + followUps: createStructuredAgentSessionDispatchFollowUps({ + host: () => host, + logger: deps.logger + }), + host: () => host + } + const registrations = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition, createAdapter }) => ({ definition, adapter: createAdapter(context) }) + ) + const agents = new StructuredAgentRegistry(registrations) const adapter = new StructuredAgentSessionAdapterRouter(agents, async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) + await Promise.all(registrations.map((registration) => registration.adapter.closeAll())) }) host = new StructuredAgentSessionHost({ store, @@ -333,7 +290,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), - ...(await modelCatalogHostDeps({ store, deps, envResolvers })) + ...(await modelCatalogHostDeps({ store, agents, deps, envResolvers })) }) setStructuredAgentSessionHost(host) return { diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts new file mode 100644 index 000000000000..9640907cead1 --- /dev/null +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + CODEX_STRUCTURED_HANDLE_NAMESPACE +} from '../../shared/agent-session-provider-handle-encoding' + +describe('what the shipped definitions let a record store', () => { + it('pins what every older build wrote', () => { + expect(CLAUDE_STRUCTURED_AGENT).toMatchObject({ + handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CLAUDE_CONFIG_DIR' + }) + expect(CODEX_STRUCTURED_AGENT).toMatchObject({ + handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CODEX_HOME' + }) + }) +}) diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 047b4a216445..f0d814637ed0 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -35,10 +35,11 @@ import { closeStructuredAgentSessionChild } from './structured-agent-session-clo import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' import type { WorktreePtyHostFence } from './worktree-pty-host-fence' import type { OrcaRuntimeService } from './orca-runtime' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' export type StructuredSessionInWorkspace = { sessionId: string - agent: 'claude' | 'codex' + agent: StructuredAgentId } export type UnclosedStructuredSession = StructuredSessionInWorkspace & { diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 24fda6b1456e..8f08b4378d3d 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -15,6 +15,7 @@ import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' import { structuredWorkerAddressable } from './structured-worker-custody' +import { isAgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' import { isStructuredWorkerHandle, structuredWorkerIdentities, @@ -115,9 +116,9 @@ export function resolveStructuredWorkerAuthority( * reconciler stamps the frozen journal archive with whatever it is told here. */ export function structuredWorkerAgent(identity: StructuredWorkerIdentity): 'claude' | 'codex' { - return ( - identity.agent ?? readStructuredAgentSessionRecord(identity.sessionId)?.provider ?? 'claude' - ) + // Workers are Claude or Codex sessions only: dispatch refuses any other agent. + const provider = readStructuredAgentSessionRecord(identity.sessionId)?.provider + return identity.agent ?? (isAgentSessionHandleProvider(provider) ? provider : 'claude') } export type StructuredWorkerObservation = { diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 047f88f19b30..1986ae112d31 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -1,37 +1,28 @@ import { describe, expect, it } from 'vitest' -import { - agentConfigDirectoryVariable, - agentSessionAccountHome, - isAgentConfigDirectoryVariable -} from './agent-session-account-home' +import { agentSessionAccountHome } 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('stores the variable and path exactly as older builds wrote them', () => { + expect( + JSON.stringify( + agentSessionAccountHome({ accountHomeVariable: 'CODEX_HOME' }, '/home/dev/.codex') + ) + ).toBe('{"variable":"CODEX_HOME","path":"/home/dev/.codex"}') }) - it('refuses a stored record whose account home names an undeclared variable', () => { + // Whether the variable is the record's own agent's is decided when its agent would start, since + // it becomes the child's environment (structured-agent-session-drivability.test.ts). + it('reads any well-formed variable, and sets aside one that is not a variable name', () => { const record = encodeAgentSessionRecord(agentSessionRecordFixture()) - expect(isPersistedAgentSessionRecord(record)).toBe(true) - expect( - isPersistedAgentSessionRecord({ - ...record, - accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } - }) - ).toBe(false) + const withVariable = (variable: string) => + isPersistedAgentSessionRecord({ ...record, accountHome: { variable, path: '/tmp/x' } }) + expect(withVariable('CODEX_HOME')).toBe(true) + expect(withVariable('GROK_HOME')).toBe(true) + for (const malformed of ['', 'A B', '1HOME', 'HOME=x', 'X'.repeat(129)]) { + expect(withVariable(malformed)).toBe(false) + } }) }) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index 2a9be3d1f12a..f33f70347583 100644 --- a/src/shared/agent-session-account-home.ts +++ b/src/shared/agent-session-account-home.ts @@ -2,12 +2,12 @@ * 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. + * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), declared + * by its definition, so a record stores it beside the path. Stored values are exactly what older + * builds wrote and read. */ -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { AgentSessionStoredAgent } from './agent-session-stored-agent' /** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ export type AgentSessionAccountHome = { @@ -17,29 +17,10 @@ export type AgentSessionAccountHome = { 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, + agent: Pick, 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) + return { variable: agent.accountHomeVariable, path } } diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index c0c2f6c6f57f..89d5a496fbe1 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -13,7 +13,6 @@ */ import type { - AgentSessionHandleProvider, AgentSessionProviderHandle, AgentSessionProviderHandleNamespace } from './agent-session-provider-handle' @@ -32,13 +31,13 @@ export const CODEX_STRUCTURED_HANDLE_NAMESPACE = { agent: 'codex' } as const satisfies AgentSessionProviderHandleNamespace -/** The namespace a record's handles live in, from the structured lane it was created on. */ -export function agentSessionProviderHandleNamespace( - provider: AgentSessionHandleProvider -): AgentSessionProviderHandleNamespace { - return provider === 'claude' +/** The typed lanes' namespaces: their handles can only ever be stored in these. */ +function builtInHandleNamespace(agent: string): AgentSessionProviderHandleNamespace | null { + return agent === CLAUDE_STRUCTURED_HANDLE_NAMESPACE.agent ? CLAUDE_STRUCTURED_HANDLE_NAMESPACE - : CODEX_STRUCTURED_HANDLE_NAMESPACE + : agent === CODEX_STRUCTURED_HANDLE_NAMESPACE.agent + ? CODEX_STRUCTURED_HANDLE_NAMESPACE + : null } export function isAgentSessionProviderHandleInNamespace( @@ -48,15 +47,19 @@ export function isAgentSessionProviderHandleInNamespace( return handle.transport === namespace.transport && handle.agent === namespace.agent } -/** Whether a handle belongs to a record of this lane. Compares namespaces; never reads its data. */ +/** + * Whether a handle belongs to a record of this agent. Compares namespaces; never reads its data. + * Claude and Codex handles are pinned to their typed lane's transport. Another agent's handle may be + * in any transport: the record store asks only that a chain keep one namespace owned by the + * record's agent, and whether this build speaks that transport is asked when the agent would start + * (`agentDrivesSession`). + */ export function agentSessionProviderHandleBelongsTo( handle: AgentSessionProviderHandle, - provider: AgentSessionHandleProvider + agent: string ): boolean { - return isAgentSessionProviderHandleInNamespace( - handle, - agentSessionProviderHandleNamespace(provider) - ) + const builtIn = builtInHandleNamespace(agent) + return builtIn ? isAgentSessionProviderHandleInNamespace(handle, builtIn) : handle.agent === agent } /** Claude's resume cursor is its leaf: the transcript entry a resume continues from. */ @@ -100,6 +103,12 @@ function isNamespacePart(value: unknown): value is string { return typeof value === 'string' && NAMESPACE_PART_PATTERN.test(value) } +/** An agent id a host may register: the same bounded slug a handle's agent is. Whether a host runs + * it is that host's registry's answer, not this check's. */ +export function isStructuredAgentId(value: unknown): value is string { + return isNamespacePart(value) +} + function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean { return ( isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) || diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index 3bf8fb69b06c..eb5c788d54b1 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -27,14 +27,23 @@ import { export { agentSessionProviderHandleKey, agentSessionProviderHandleRoot, - isAgentSessionProviderHandle + isAgentSessionProviderHandle, + isStructuredAgentId } from './agent-session-provider-handle-encoding' -/** The structured lanes this build drives. A record names one; a handle never does. */ +/** + * The structured agents every build ships. They are not the whole set: a host may register more, + * advertises that it does with the registered-agents capability, and lists them through + * `agentSession.agents`. A client that has not learned a host's list knows only these. + */ export const AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS = ['claude', 'codex'] as const export type AgentSessionHandleProvider = (typeof AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS)[number] +/** A structured agent a host registered: one of the built-ins or any agent id the host lists. + * Checked as a slug by `isStructuredAgentId`; whether a host runs it is its registry's answer. */ +export type StructuredAgentId = string + /** Runtime guard for persisted/remote provider metadata. Unknown values must not impersonate Codex. */ export function isAgentSessionHandleProvider(value: unknown): value is AgentSessionHandleProvider { return value === 'claude' || value === 'codex' diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 9ed80d875ecd..f8dea65428ff 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -22,14 +22,13 @@ import { } from './agent-session-conversation-command' import { decodePersistedAgentSessionProviderHandleChain, - type AgentSessionHandleProvider, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' -import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' import { - isAgentConfigDirectoryVariable, - type AgentSessionAccountHome -} from './agent-session-account-home' + isAgentSessionProviderHandleInNamespace, + isStructuredAgentId +} from './agent-session-provider-handle-encoding' +import type { AgentSessionAccountHome } from './agent-session-account-home' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -134,7 +133,8 @@ export type AgentSessionRecord = { schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION sessionId: string location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider + /** The agent this session names, whether this build can run it or not. */ + provider: string providerHandleChain: AgentSessionProviderHandleLink[] accountHome: AgentSessionAccountHome /** Provider options the user chose, replayed whenever a new owner starts the session. */ @@ -228,13 +228,19 @@ export function isAgentSessionProcessIdentity( ) } +const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ + +/** Shape only: whether the variable is the one the record's agent pins is a launch-time question + * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */ function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { if (typeof value !== 'object' || value === null) { return false } const home = value as Partial return ( - isAgentConfigDirectoryVariable(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) + typeof home.variable === 'string' && + ENVIRONMENT_VARIABLE_NAME.test(home.variable) && + isBoundedString(home.path, MAX_PATH_LENGTH) ) } @@ -334,8 +340,7 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe ) } -/** The on-disk shape, which still admits the removed terminal handoff's lease values and stores - * handles in their typed form. Decode through `decodePersistedAgentSessionRecord` before use. */ +/** Stored identity is independent of registrations; availability is checked only at start. */ export function isPersistedAgentSessionRecord( value: unknown ): value is PersistedAgentSessionRecord { @@ -347,7 +352,7 @@ export function isPersistedAgentSessionRecord( record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && - (record.provider === 'claude' || record.provider === 'codex') && + isStructuredAgentId(record.provider) && isAgentSessionAccountHome(record.accountHome) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && @@ -368,9 +373,15 @@ export function isPersistedAgentSessionRecord( // The row holds stored handles; validate the chain they decode to. const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) const head = chain?.at(-1) + // One namespace, owned by the record's own agent; which transport is the chain's own fact. + const transport = chain?.[0]?.handle.transport + const namespace = transport === undefined ? null : { transport, agent: validated.provider } return ( chain !== null && - chain.every((link) => agentSessionProviderHandleBelongsTo(link.handle, validated.provider)) && + chain.every( + (link) => + namespace !== null && isAgentSessionProviderHandleInNamespace(link.handle, namespace) + ) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && head?.linkId === validated.lease.provenHandleLinkId && diff --git a/src/shared/agent-session-registered-agents.test.ts b/src/shared/agent-session-registered-agents.test.ts new file mode 100644 index 000000000000..9e9427c3a7c6 --- /dev/null +++ b/src/shared/agent-session-registered-agents.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { decodeAgentSessionAgentsResult } from './agent-session-registered-agents' + +const CODEX = { + agent: 'codex', + capabilities: { + rewind: true, + compact: true, + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + } +} + +describe('decodeAgentSessionAgentsResult', () => { + it('reads every well-formed agent', () => { + expect(decodeAgentSessionAgentsResult({ agents: [CODEX] })).toEqual([CODEX]) + }) + + it('drops a row it cannot read and keeps the rest', () => { + expect( + decodeAgentSessionAgentsResult({ + agents: [ + { agent: 'not an id!', capabilities: CODEX.capabilities }, + { agent: 'grok' }, + { agent: 'opencode', capabilities: { ...CODEX.capabilities, rewind: 'yes' } }, + CODEX + ] + }) + ).toEqual([CODEX]) + }) + + it('degrades an arm or flag a newer host adds to the one that claims least', () => { + expect( + decodeAgentSessionAgentsResult({ + agents: [ + { + agent: 'grok', + capabilities: { + steering: 'interrupt', + approvalEnforcement: 'sandbox', + someLaterCapability: true + } + } + ] + }) + ).toEqual([ + { + agent: 'grok', + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' + } + } + ]) + }) + + it('keeps the first row of a repeated agent', () => { + const later = { ...CODEX, capabilities: { ...CODEX.capabilities, rewind: false } } + expect(decodeAgentSessionAgentsResult({ agents: [CODEX, later] })).toEqual([CODEX]) + }) + + it('answers null for a reply that is not an agent list', () => { + expect(decodeAgentSessionAgentsResult(null)).toBeNull() + expect(decodeAgentSessionAgentsResult({})).toBeNull() + expect(decodeAgentSessionAgentsResult({ agents: 'claude' })).toBeNull() + }) +}) diff --git a/src/shared/agent-session-registered-agents.ts b/src/shared/agent-session-registered-agents.ts new file mode 100644 index 000000000000..fc4b16f90014 --- /dev/null +++ b/src/shared/agent-session-registered-agents.ts @@ -0,0 +1,73 @@ +/** + * `agentSession.agents`: the structured agents a host registered, each with the capability record + * its definition declares, so a client can show what an agent supports before any session of it + * exists. Published only by hosts advertising the registered-agents capability. + * + * A host is newer than some of its clients, so the reply is read row by row: a row this build + * cannot read is dropped and the rest are kept, and an arm this build does not know degrades to the + * one that claims the least. + */ + +import { z } from 'zod' +import type { AgentSessionCapabilities } from './agent-session-capabilities' +import { isStructuredAgentId } from './agent-session-provider-handle-encoding' +import { openEnum } from './zod-salvage' + +export const AGENT_SESSION_AGENTS_METHOD = 'agentSession.agents' + +export type AgentSessionRegisteredAgent = { + agent: string + capabilities: AgentSessionCapabilities +} + +export type AgentSessionAgentsResult = { + agents: AgentSessionRegisteredAgent[] +} + +/** Bounds one reply; no host registers anywhere near this many agents. */ +const MAX_REGISTERED_AGENTS = 128 + +// An absent flag is a capability the host did not claim. +const capabilityFlag = z.boolean().optional().default(false) + +const registeredAgentSchema = z.object({ + agent: z.string().refine(isStructuredAgentId), + capabilities: z.object({ + rewind: capabilityFlag, + compact: capabilityFlag, + threadGoal: capabilityFlag, + contextUsage: capabilityFlag, + imagePrompts: capabilityFlag, + // Holding a follow-up until the turn ends never interrupts one the client cannot reason about. + steering: openEnum(['inject', 'queue'] as const, 'queue') + .optional() + .default('queue'), + // `orca` claims only that Orca answers the agent's requests, never that it confines the agent. + approvalEnforcement: openEnum(['provider', 'orca'] as const, 'orca') + .optional() + .default('orca') + }) +}) + +/** The host's agents as this build reads them, or null when the reply is not an agent list. */ +export function decodeAgentSessionAgentsResult( + value: unknown +): AgentSessionRegisteredAgent[] | null { + if (typeof value !== 'object' || value === null || !('agents' in value)) { + return null + } + const { agents } = value + if (!Array.isArray(agents)) { + return null + } + const seen = new Set() + const decoded: AgentSessionRegisteredAgent[] = [] + for (const row of agents.slice(0, MAX_REGISTERED_AGENTS)) { + const parsed = registeredAgentSchema.safeParse(row) + if (parsed.success && !seen.has(parsed.data.agent)) { + seen.add(parsed.data.agent) + decoded.push(parsed.data) + } + } + return decoded +} diff --git a/src/shared/agent-session-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts new file mode 100644 index 000000000000..616ff054af1a --- /dev/null +++ b/src/shared/agent-session-stored-agent.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { isPersistedAgentSessionRecord, type AgentSessionRecord } from './agent-session-record' +import { agentSessionRecordFixture } from './agent-session-record.test-fixture' +import { + decodePersistedAgentSessionRecord, + encodeAgentSessionRecord +} from './agent-session-record-stored-form' + +function grokRecord(transport = 'acp'): AgentSessionRecord { + const record = agentSessionRecordFixture() + return { + ...record, + provider: 'grok', + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { transport, agent: 'grok', nativeId: 'grok-session-1', resumeCursor: '{"cwd":"/w"}' } + })), + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' } + } +} + +describe('provider-independent stored records', () => { + it('reads and decodes a well-formed record', () => { + const stored = encodeAgentSessionRecord(grokRecord()) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { + return + } + expect(decodePersistedAgentSessionRecord(stored).record).toEqual(grokRecord()) + }) + + it('reads a saved record without a provider registration', () => { + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord()))).toBe(true) + }) + + it.each(['', '1grok', 'gro k', 'grok/', 'g'.repeat(65)])( + 'quarantines a malformed provider id %j even without a handle', + (provider) => { + const record = grokRecord() + expect( + isPersistedAgentSessionRecord({ + ...encodeAgentSessionRecord(record), + provider, + providerHandleChain: [], + lease: { ...record.lease, claimStatus: 'released' } + }) + ).toBe(false) + } + ) + + // Whether this build can drive the chain's transport is decided when its agent would start + // (structured-agent-session-drivability.test.ts), so a definition change never hides a chat. + it("reads a record whose handles are in a transport other than the agent's current one", () => { + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord('other')))).toBe(true) + }) + + it('sets aside a record whose handles name another agent, or mix transports', () => { + const record = grokRecord() + const [link] = record.providerHandleChain + const foreign = { ...link!, handle: { ...link!.handle, agent: 'codex' } } + const mixed = { ...link!, linkId: 'second', handle: { ...link!.handle, transport: 'other' } } + for (const chain of [[foreign], [link!, mixed]]) { + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord({ ...record, providerHandleChain: chain }) + ) + ).toBe(false) + } + }) + + it('keeps reading Claude and Codex records exactly as before', () => { + const stored = encodeAgentSessionRecord(agentSessionRecordFixture()) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + expect(JSON.stringify(stored.providerHandleChain[0]?.handle)).toBe( + '{"provider":"claude","sessionId":"provider-session-alpha-1","leafUuid":null}' + ) + }) + + it('refuses a Claude record whose handles another agent owns', () => { + const record = agentSessionRecordFixture() + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord({ ...record, provider: 'codex', accountHome: record.accountHome }) + ) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts new file mode 100644 index 000000000000..a500a604de7c --- /dev/null +++ b/src/shared/agent-session-stored-agent.ts @@ -0,0 +1,13 @@ +/** The identity an agent must match when starting a stored session. */ + +import type { AgentSessionProviderTransport } from './agent-session-provider-handle' + +/** What an agent's definition says its records pin. */ +export type AgentSessionStoredAgent = { + /** The Orca agent a record names as its `provider`. */ + agent: string + /** The protocol whose id space this agent's provider handles live in. */ + handleTransport: AgentSessionProviderTransport + /** Environment variable naming the agent's config directory, pinned as the record's account home. */ + accountHomeVariable: string +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4715f220a46b..2789bef86ab0 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -230,6 +230,14 @@ export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.claude.v1' as const +// Why: a host's structured agents are the ones it registered, not a list every build ships. A host +// advertising this accepts any agent it lists through `agentSession.agents` (with each agent's +// capability record) in `agentSession.*` params, and refuses one it did not register; a client +// offers an agent beyond Claude and Codex only to such a host. A client advertising it renders an +// `agent-session` tab of any agent its host lists; the host withholds every other agent's tabs from +// a client that does not (an older client would list them with an empty pane). +export const STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY = + 'agent-session.structured.registered-agents.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host // to stop provider children after the last surface closes without tying lifetime to a transport. export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = @@ -399,6 +407,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_COMPLETION_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d6d81433e8e0..7276915e1d93 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -472,6 +472,7 @@ import { } from './speech-params' import { SshTarget } from './ssh-params' import { + AgentsParams, AttachParams, CancelParams, ConversationCommandParams, @@ -585,6 +586,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agent.launch': AgentLaunch, 'agent.launchReplay': AgentLaunchReplay, 'agentHooks.prepareCodexForWslPane': PrepareCodexForWslPaneParams, + 'agentSession.agents': AgentsParams, 'agentSession.cancel': CancelParams, 'agentSession.close': OptionsParams, 'agentSession.commands': OptionsParams, diff --git a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts new file mode 100644 index 000000000000..a54be6e3e723 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + AgentsParams, + AttachParams, + CreateIntentParams, + CreateSupportParams, + ModelCatalogParams +} from './structured-agent-session-params' + +const ENVELOPE = { + sessionId: 'agent-session-0123456789abcdef0123456789abcdef', + clientOperationId: 'op-1', + expectedRuntimeFence: null, + payloadFingerprint: 'a'.repeat(64) +} + +describe('structured agent params', () => { + it.each(['claude', 'codex', 'grok', 'qwen-code'])('accept the agent id %s', (agent) => { + expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(true) + expect(ModelCatalogParams.safeParse({ agent }).success).toBe(true) + expect( + CreateIntentParams.safeParse({ envelope: ENVELOPE, worktree: 'wt', agent }).success + ).toBe(true) + }) + + it.each(['', ' claude', 'grok agent', '../codex', 'a'.repeat(65), 7])( + 'refuse what is not an agent id: %s', + (agent) => { + expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(false) + expect(ModelCatalogParams.safeParse({ agent }).success).toBe(false) + } + ) + + it('keeps attaching by a client-supplied handle to Claude and Codex', () => { + const attach = { + envelope: ENVELOPE, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/u/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + expect(AttachParams.safeParse(attach).success).toBe(true) + expect(AttachParams.safeParse({ ...attach, provider: 'grok' }).success).toBe(false) + }) + + it('takes nothing for the agent list', () => { + expect(AgentsParams.safeParse({}).success).toBe(true) + expect(AgentsParams.safeParse({ agent: 'grok' }).success).toBe(false) + }) +}) diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index a1f2530ac5ed..5bedb40d784e 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionId } from '../agent-session-record' +import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' import { normalizeExecutionHostId } from '../execution-host' import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, @@ -73,6 +74,10 @@ export const ProviderHandle = z.discriminatedUnion('kind', [ .strict() ]) +/** Any agent a host may register. The host refuses one it did not register; a client sends one + * beyond Claude and Codex only to a host advertising the registered-agents capability. */ +export const StructuredAgent = z.string().refine(isStructuredAgentId, 'Invalid agent') + export const ExecutionHostId = z .string() .max(MAX_ID_LENGTH) @@ -97,6 +102,8 @@ export const AccountHome = z }) .strict() +/** Attaching by a client-supplied handle stays Claude/Codex: only their handles have a wire form, and + * every client creates other agents by intent, which the host resolves. */ export const AttachParams = z .object({ envelope: MutationEnvelope, @@ -122,7 +129,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']), + agent: StructuredAgent, resumeFrom: ResumeSource.optional(), /** * The tab id the client reserved for this chat, so it can place the tab before the reply. The @@ -141,7 +148,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']) + agent: StructuredAgent }) .strict() @@ -291,13 +298,16 @@ export const SetOptionParams = z export const OptionsParams = z.object({ sessionId: SessionId }).strict() +/** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */ +export const AgentsParams = z.object({}).strict() + /** `sessionId` scopes the catalog to that session's pinned account; without a * session record the host keys it by the account a new launch would pin. * `worktree` names where a new chat runs, whose own config may replace the default. * `waitForListing` holds the answer until the listing the host reported in progress lands; send * it only after that report, because a host that predates it refuses the unknown key. */ export const ModelCatalogParams = z.strictObject({ - agent: z.enum(['claude', 'codex']), + agent: StructuredAgent, sessionId: SessionId.optional(), worktree: Identifier('Invalid worktree selector').optional(), waitForListing: z.boolean().optional() diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 563637c562f3..68a030dedb6a 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -4,6 +4,7 @@ import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { TerminalColorOverrides } from './terminal-color-overrides' import type { TerminalLayoutSnapshot } from './terminal-tab-types' import type { TuiAgent } from './tui-agent' +import type { StructuredAgentId } from './agent-session-provider-handle' export type RuntimeMobileSessionTerminalTab = { type: 'terminal' @@ -97,7 +98,9 @@ export type RuntimeMobileSessionAgentTab = { title: string sessionId: string replacesSessionId?: string - agent: 'claude' | 'codex' + /** An agent the host registered. Beyond Claude and Codex, published only to clients advertising + * the registered-agents capability. */ + agent: StructuredAgentId color?: string | null isPinned?: boolean isActive: boolean diff --git a/src/shared/structured-agent-session-child-work-legacy.ts b/src/shared/structured-agent-session-child-work-legacy.ts index 72c0d0db94ce..52e7c1180a4f 100644 --- a/src/shared/structured-agent-session-child-work-legacy.ts +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -11,7 +11,7 @@ // must be decided here for those older readers too. `structuredChildWorkLegacySubagents` is not wire: // it feeds in-app readers of `AgentStatusEntry.subagents`, and dies when they read `children`. -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { StructuredAgentId } from './agent-session-provider-handle' import { codexChildCommandDescription } from './codex-child-command-description' import { projectAgentChildWorkLegacyBackgroundTasks, @@ -29,7 +29,7 @@ export function codexAgentBackgroundTaskId(threadId: string): string { function withLegacyIds( views: readonly AgentChildWorkView[], - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): AgentChildWorkView[] { return views.map((view) => provider === 'codex' && view.kind === 'agent' && view.providerId !== undefined @@ -59,7 +59,7 @@ function withCodexChildCommands(views: readonly AgentChildWorkView[]): AgentChil export function structuredChildWorkLegacyTasks( views: readonly AgentChildWorkView[], - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): AgentChildWorkLegacyBackgroundProjection { return projectAgentChildWorkLegacyBackgroundTasks( withLegacyIds(provider === 'codex' ? withCodexChildCommands(views) : views, provider) @@ -68,7 +68,7 @@ export function structuredChildWorkLegacyTasks( export function structuredChildWorkLegacySubagents( views: readonly AgentChildWorkView[], - provider: AgentSessionHandleProvider + provider: StructuredAgentId ): AgentSubagentSnapshot[] | undefined { return projectAgentChildWorkLegacySubagents(withLegacyIds(views, provider)) } diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 4199364cac8f..cb4b48d3a9d5 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -4,7 +4,10 @@ */ import { describe, expect, it } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from './protocol-version' import { agentTabsDefaultToNativeChat, prefersStructuredNativeChatByDefault, @@ -107,3 +110,38 @@ describe('per-launch structured feasibility', () => { expect(support({ workspaceKind: 'folder' })).toEqual({ supported: true }) }) }) + +describe('agents beyond Claude and Codex', () => { + const REGISTERED = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] + + it('are offered only by a host that advertises and lists them', () => { + expect( + support({ agent: 'grok', hostCapabilities: REGISTERED, hostStructuredAgents: ['grok'] }) + ).toEqual({ supported: true }) + }) + + it('are not offered by a host that does not advertise its registered agents', () => { + expect(support({ agent: 'grok', hostStructuredAgents: ['grok'] })).toEqual({ + supported: false, + blocker: 'runtime-capability' + }) + }) + + it('are not offered when the host did not list them', () => { + expect(support({ agent: 'grok', hostCapabilities: REGISTERED })).toEqual({ + supported: false, + blocker: 'agent-without-structured-session' + }) + expect( + support({ agent: 'grok', hostCapabilities: REGISTERED, hostStructuredAgents: ['claude'] }) + ).toEqual({ supported: false, blocker: 'agent-without-structured-session' }) + }) + + it('leave Claude and Codex offered by hosts that predate the capability', () => { + expect(support({ agent: 'claude' })).toEqual({ supported: true }) + expect(support({ agent: 'codex' })).toEqual({ supported: true }) + }) +}) diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 7fc9f9964363..9f134bc95833 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -14,6 +14,7 @@ import type { GlobalSettings } from './global-settings-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from './protocol-version' import type { TuiAgent } from './tui-agent' @@ -59,6 +60,9 @@ export type StructuredNativeChatSupportInput = { startsOutsideWorkspaceRoot?: boolean /** An existing PTY agent keeps its execution transport. */ reusesTerminal?: boolean + /** The agents the host listed through `agentSession.agents`. Absent: none learned, so only + * Claude and Codex, which every structured host runs, can be offered. */ + hostStructuredAgents?: readonly string[] } /** The user's default for a new agent tab: native chat rather than the raw TUI. */ @@ -98,7 +102,8 @@ export function resolveStructuredNativeChatSupport( if (input.reusesTerminal === true) { return { supported: false, blocker: 'reused-terminal' } } - if (!isAgentSessionHandleProvider(input.agent)) { + const builtInAgent = isAgentSessionHandleProvider(input.agent) + if (!builtInAgent && !input.hostStructuredAgents?.includes(input.agent)) { return { supported: false, blocker: 'agent-without-structured-session' } } if (input.workspaceKind === 'floating') { @@ -114,7 +119,14 @@ export function resolveStructuredNativeChatSupport( if (input.hostCapabilities === null) { return { supported: false, blocker: 'runtime-capability-unknown' } } - if (!input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + if ( + !input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) || + // A host that does not advertise its registered agents accepts only Claude and Codex. + (!builtInAgent && + !input.hostCapabilities.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + )) + ) { return { supported: false, blocker: 'runtime-capability' } } if (host.kind === 'runtime') { diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 852bb948e354..61c25e39ad43 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -608,6 +608,46 @@ describe('parseWorkspaceSession', () => { } }) + it('keeps a chat tab of any agent id, and drops only a malformed id', () => { + const chatTab = (id: string, agentSessionAgent: unknown) => ({ + id, + entityId: id, + groupId: 'group1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent, + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + }) + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + chatTab('a', 'grok'), + chatTab('b', 'not an agent!'), + chatTab('c', 42), + chatTab('d', 'claude') + ] + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt.map((tab) => [tab.id, tab.agentSessionAgent])).toEqual([ + ['a', 'grok'], + ['b', undefined], + ['c', undefined], + ['d', 'claude'] + ]) + } + }) + it('degrades an unknown viewMode to the safe default instead of failing parse', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 54448e1e5478..9f983c832820 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -38,6 +38,7 @@ import { workspaceVisibleTabTypeSchema } from './workspace-session-tab-type-schema' import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' +import { isStructuredAgentId } from './agent-session-provider-handle-encoding' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -133,7 +134,14 @@ const tabSchema = z.object({ worktreeId: z.string(), executionHostId: executionHostIdSchema.optional(), contentType: tabContentTypeSchema, - agentSessionAgent: z.enum(['codex', 'claude']).optional().catch(undefined), + // Why: any agent a host registered, as the host published it. An id that is not an agent slug + // degrades to absent, which renders no chat, rather than failing the whole-session parse; a + // build that predates an agent reads its tab the same way. + agentSessionAgent: z + .string() + .refine((value) => isStructuredAgentId(value)) + .optional() + .catch(undefined), label: z.string(), generatedLabel: z.string().nullable().optional(), aiVaultTitle: z diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts new file mode 100644 index 000000000000..2f45ad6f345c --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -0,0 +1,394 @@ +// A structured agent beyond Claude and Codex, paired across two builds: current code against the +// newest published release. +// +// A host now accepts any agent it registered, publishes those agents, and stores their records. +// Clients and hosts update independently, so each skew must hold before such an agent ships: +// +// - an old client paired with a new host never meets that agent's tab, and the Claude and Codex +// tabs it does meet are what its own build would have published; +// - an old build reading what a new one stored (a tab, a record) keeps every other tab and never +// mistakes the new agent's record for another provider; +// - a new client against an old host does not offer the agent, because the capability is absent. +// +// The old side's lists are read from its checkout and the capability removed from them, so this +// stays exercised after a release ships the capability (docs/reference/remote-wire-compatibility.md). +// What the old side stores is its own code, so those expectations follow what the baseline host +// advertises, never its version. + +import { beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../src/main/ipc/desktop-renderer-runtime-capabilities' +import { projectSessionTabAgentStatus } from '../../../src/main/runtime/rpc/methods/session-tab-agent-status-projection' +import { agentSessionRecordFixture } from '../../../src/shared/agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from '../../../src/shared/agent-session-record-stored-form' +import { + isPersistedAgentSessionRecord, + type AgentSessionRecord +} from '../../../src/shared/agent-session-record' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { + RUNTIME_CAPABILITIES, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../src/shared/runtime-types' +import { resolveStructuredNativeChatSupport } from '../../../src/shared/structured-native-chat-launch-route' +import { parseWorkspaceSession } from '../../../src/shared/workspace-session-schema' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const NEW_AGENT = 'grok' +const WORKTREE = 'wt-1' + +type Baseline = { + ref: string + wire: AgentSessionWireBuild + desktopClientCapabilities: readonly string[] + /** Releases before the host admitted by client capability alone also took the host's chat + * setting; passing it on is harmless to one that no longer reads it. */ + projectSessionTabAgentStatus: ( + payload: RuntimeMobileSessionTabsSnapshot, + clientKind: 'mobile' | 'runtime', + clientCapabilities: readonly string[], + structuredNativeChatEnabled: boolean + ) => RuntimeMobileSessionTabsSnapshot + parseWorkspaceSession: (raw: unknown) => { ok: boolean; value?: unknown } + isStructuredTab: (tab: unknown) => boolean + /** The optional list supports releases that gated stored reads on registration. */ + isPersistedAgentSessionRecord: (value: unknown, agents?: unknown) => boolean +} + +let baseline: Baseline +let current: AgentSessionWireBuild + +function member(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the baseline release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a release module is typed unknown; the name is the same export this build calls, and a missing one throws above. + return value as T +} + +beforeAll(async () => { + const ref = resolveBaselineReleaseRef() + const checkout = await materializeReleaseCheckout(ref) + const [capabilities, projection, schema, tabs, record] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/ipc/desktop-renderer-runtime-capabilities.ts'), + importReleaseCheckoutModule( + checkout, + 'src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts' + ), + importReleaseCheckoutModule(checkout, 'src/shared/workspace-session-schema.ts'), + importReleaseCheckoutModule( + checkout, + 'src/renderer/src/components/native-chat/structured-agent-session-tabs.ts' + ), + importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') + ]) + current = await loadAgentSessionWireBuild(WORKING_TREE) + const wire = await loadAgentSessionWireBuild(ref) + baseline = { + ref, + wire, + desktopClientCapabilities: member(capabilities, 'DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES'), + projectSessionTabAgentStatus: member(projection, 'projectSessionTabAgentStatus'), + parseWorkspaceSession: member(schema, 'parseWorkspaceSession'), + isStructuredTab: member(tabs, 'isStructuredTab'), + isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord') + } +}, SUITE_TIMEOUT_MS) + +/** Whether a build's saved tabs support open agent ids. */ +function registersAgents(build: AgentSessionWireBuild): boolean { + return build.capabilities.includes(STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY) +} + +function baselineReadsRecord(record: AgentSessionRecord): boolean { + return baseline.isPersistedAgentSessionRecord( + encodeAgentSessionRecord(record), + new Set(['claude', 'codex']) + ) +} + +/** What a desktop too old to render the host's agents advertises: its own list without the + * capability, so the day a release ships it this still describes a client that predates it. */ +function oldDesktopClientCapabilities(): string[] { + return baseline.desktopClientCapabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} + +/** What a host too old to register agents advertises, derived the same way. */ +function oldHostCapabilities(): string[] { + return baseline.wire.capabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} + +function chatTab(agent: string, isActive: boolean) { + return { + type: 'agent-session' as const, + id: `agent-session:${agent}-session-1`, + title: `${agent} chat`, + sessionId: `${agent}-session-1`, + agent, + isActive + } +} + +function tabsSnapshot(agents: readonly string[]): RuntimeMobileSessionTabsSnapshot { + const tabs = agents.map((agent, index) => chatTab(agent, index === 0)) + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: tabs[0]?.id ?? null, + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-1', + activeTabId: tabs[0]?.id ?? null, + tabOrder: tabs.map((tab) => tab.id) + } + ], + tabs + } +} + +function persistedChatTab(id: string, agentSessionAgent: string) { + return { + id, + entityId: id, + groupId: 'group-1', + worktreeId: WORKTREE, + contentType: 'agent-session', + agentSessionAgent, + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +/** A workspace session as this build persists it with chats of three agents open. */ +function persistedSession(): unknown { + return { + activeRepoId: null, + activeWorktreeId: WORKTREE, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + [WORKTREE]: [ + persistedChatTab('chat-claude', 'claude'), + persistedChatTab('chat-new-agent', NEW_AGENT), + persistedChatTab('chat-codex', 'codex') + ] + }, + activeTabTypeByWorktree: { [WORKTREE]: 'agent-session' } + } +} + +function newAgentRecord(): AgentSessionRecord { + const record = agentSessionRecordFixture() + return { + ...record, + provider: NEW_AGENT, + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { transport: 'acp', agent: NEW_AGENT, nativeId: 'native-session-1' } + })), + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' } + } +} + +/** A parsed session's chat tabs in the test worktree, read through a named shape. */ +const parsedSessionTabs = z.object({ + unifiedTabs: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))) +}) + +function unifiedTabsOf(parsed: { ok: boolean; value?: unknown }): Record[] { + const session = parsedSessionTabs.safeParse(parsed.value) + return session.success ? (session.data.unifiedTabs[WORKTREE] ?? []) : [] +} + +describe('a structured agent beyond Claude and Codex, across versions', () => { + it( + 'pairs current code with a real published release', + () => { + expect(baseline.ref).toMatch(/^v?\d/) + // Anti-vacuous: the old desktop still says it reads structured chats, so a withheld tab + // below is this capability's gate answering, not the whole surface being refused. + expect(oldDesktopClientCapabilities()).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + }, + SUITE_TIMEOUT_MS + ) + + describe('old client, new host', () => { + it("never receives the new agent's tab, and its Claude and Codex tabs are what its own build publishes", () => { + const client = oldDesktopClientCapabilities() + const projected = projectSessionTabAgentStatus( + tabsSnapshot([NEW_AGENT, 'claude', 'codex']), + 'runtime', + client + ) + expect(projected.tabs.map((tab) => tab.id)).not.toContain(chatTab(NEW_AGENT, false).id) + expect(projected.tabGroups?.[0]?.tabOrder).not.toContain(chatTab(NEW_AGENT, false).id) + // Same-version reference: the release publishing the same chats to its own client. + const reference = baseline.projectSessionTabAgentStatus( + tabsSnapshot(['claude', 'codex']), + 'runtime', + client, + true + ) + expect(reference.tabs.length).toBe(2) + expect(projected.tabs).toEqual(reference.tabs) + }) + + it('keeps every other tab when it loads a session a newer build saved', () => { + const parsed = baseline.parseWorkspaceSession(persistedSession()) + expect(parsed.ok).toBe(true) + const tabs = unifiedTabsOf(parsed) + expect(tabs.map((tab) => tab.id)).toEqual(['chat-claude', 'chat-new-agent', 'chat-codex']) + if (registersAgents(baseline.wire)) { + // It already keeps any agent's id; whether it mounts that chat is its own client's call. + expect(tabs.map((tab) => tab.agentSessionAgent)).toEqual(['claude', NEW_AGENT, 'codex']) + expect([tabs[0], tabs[2]].map((tab) => baseline.isStructuredTab(tab))).toEqual([true, true]) + } else { + expect(tabs.map((tab) => tab.agentSessionAgent)).toEqual(['claude', undefined, 'codex']) + // The tab it cannot place stays listed and mounts no chat pane. + expect(tabs.map((tab) => baseline.isStructuredTab(tab))).toEqual([true, false, true]) + } + // This build reads the same saved session with the agent intact. + expect( + unifiedTabsOf(parseWorkspaceSession(persistedSession())).map((tab) => tab.agentSessionAgent) + ).toEqual(['claude', NEW_AGENT, 'codex']) + }) + + it('classifies a saved new-agent record and keeps Claude and Codex records readable', () => { + // Older admission gates may refuse it; provider-independent releases can read it. + expect(baselineReadsRecord(newAgentRecord())).toBeTypeOf('boolean') + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(newAgentRecord()))).toBe(true) + const claude = agentSessionRecordFixture() + const codex: AgentSessionRecord = { + ...claude, + provider: 'codex', + providerHandleChain: claude.providerHandleChain.map((link) => ({ + ...link, + handle: codexProviderHandle('thread-1') + })), + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' } + } + for (const record of [claude, codex]) { + expect(baselineReadsRecord(record)).toBe(true) + } + }) + }) + + describe('new client, old host', () => { + it('does not offer the new agent to a host without the capability', () => { + const host = oldHostCapabilities() + expect( + resolveStructuredNativeChatSupport({ + agent: NEW_AGENT, + executionHostId: 'local', + hostCapabilities: host, + hostStructuredAgents: [NEW_AGENT], + workspaceKind: 'git-worktree' + }) + ).toEqual({ supported: false, blocker: 'runtime-capability' }) + // Claude and Codex are offered exactly when the old host serves structured chats at all. + for (const agent of ['claude', 'codex'] as const) { + expect( + resolveStructuredNativeChatSupport({ + agent, + executionHostId: 'local', + hostCapabilities: host, + workspaceKind: 'git-worktree' + }).supported + ).toBe(host.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) + } + }) + + it('would be refused by an old host that does not advertise the capability', async () => { + if ( + baseline.wire.capabilities.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) + ) { + return + } + const replies: { ok: boolean; result?: unknown }[] = [] + await baseline.wire.createDispatcher(createSupportRuntime()).dispatchStreaming( + { + id: 'create-support', + authToken: 'cross-version-token', + method: 'agentSession.createSupport', + params: { worktree: `id:${WORKTREE}`, agent: NEW_AGENT } + }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: baseline.wire.capabilities } + ) + expect(replies).toHaveLength(1) + expect(replies[0]?.ok).toBe(false) + }) + }) + + describe('Claude and Codex, both ways', () => { + it('are accepted by both builds as before', async () => { + for (const build of [current, baseline.wire]) { + for (const agent of ['claude', 'codex']) { + const replies: { ok: boolean; result?: unknown }[] = [] + await build.createDispatcher(createSupportRuntime()).dispatchStreaming( + { + id: `create-support-${agent}`, + authToken: 'cross-version-token', + method: 'agentSession.createSupport', + params: { worktree: `id:${WORKTREE}`, agent } + }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: [...RUNTIME_CAPABILITIES] } + ) + expect(replies, `${build.label}: ${agent}`).toEqual([ + expect.objectContaining({ ok: true, result: { supported: true } }) + ]) + } + } + }) + + it("reach today's desktop unchanged from a new host", () => { + const payload = tabsSnapshot(['claude', 'codex']) + expect( + projectSessionTabAgentStatus( + payload, + 'runtime', + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + ) + ).toBe(payload) + }) + }) +}) + +/** Enough of a runtime for createSupport to answer: the verdict itself is stubbed. */ +function createSupportRuntime(): unknown { + return { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined + } +} diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 681c65f50c12..afc26dc3d998 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -1,6 +1,7 @@ import { vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { CODEX_STRUCTURED_AGENT } from '../../../src/main/codex/codex-structured-agent-definition' import { AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -93,7 +94,9 @@ export function structuredHostStub( // No opening emit, unlike the status feed above: a completion is an edge, so this stream // opens empty and a subscriber that was away has missed what passed. subscribeTurnCompletions: vi.fn(() => () => undefined), - unsubscribe: vi.fn() + unsubscribe: vi.fn(), + agentDefinitions: vi.fn(() => [CODEX_STRUCTURED_AGENT]), + knownAgentIds: vi.fn(() => [CODEX_STRUCTURED_AGENT.agent]) } } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index b999f63a6510..6174d952f697 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -187,7 +187,13 @@ export const STRUCTURED_CALLS: { }, // Teardown runs through the runtime's subscription registry rather than the // host, so its reply is the only signal that the gate opened. - { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } } + { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } }, + // The host's registered agents, each with its declared capability record. + { + method: 'agentSession.agents', + hostMethod: 'agentDefinitions', + result: { agents: [{ agent: 'codex', capabilities: { compact: true } }] } + } ] export function envelope(args: { @@ -310,6 +316,7 @@ export function paramsFor(method: string): unknown { case 'agentSession.hold': case 'agentSession.release': return { sessionId: SESSION, holderId: 'surface-1' } + case 'agentSession.agents': case 'agentSession.restartResumable': case 'agentSession.restartResumableDismiss': case 'agentSession.restartResume':