From 7a8b2cb64d0602879df5ee7e4710b7cea39e32e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:01:48 -0700 Subject: [PATCH 01/45] refactor(native-chat): keep the provider resume handle opaque to shared code Shared structured-chat code parsed each provider's resume handle: Claude's session id and branch leaf, Codex's thread id, through a 'claude' | 'codex' union every new agent had to widen. The in-memory handle is now { transport, agent, nativeId, providerData? }: shared readers use nativeId, lease and handle-chain checks compare transport and agent, and only the Claude adapter reads its leaf (providerData). Stored and wire forms are unchanged for Claude and Codex. One encoding module writes their typed shapes and decodes both those and the neutral shape a new transport uses, which an older build refuses as unreadable rather than reading as Codex. Key and root strings, which fork seeds, superseded creations and resume offers persist, stay byte-identical. The journal's own handle type becomes the journal-row and attach-wire encoding of the same handle, and the journal identity carries the neutral handle (null before the provider proves one). No user-visible change. --- .../journal-replay-retention-benchmark.mjs | 9 +- .../structured-session-ownership.test.ts | 10 +- .../claude-api-retry-idle-sweep.test.ts | 3 +- src/main/claude/claude-api-retry-row.test.ts | 3 +- .../claude-context-usage-restart.test.ts | 3 +- ...claude-context-usage-unloaded-turn.test.ts | 3 +- .../claude/claude-informational-row.test.ts | 3 +- .../claude-stream-json-connection.test.ts | 3 +- .../claude-structured-acquisition-launch.ts | 10 +- .../claude-structured-auth-parity.test.ts | 6 +- .../claude-structured-history-window.test.ts | 3 +- .../claude-structured-history-window.ts | 16 +- ...ude-structured-journal-translation.test.ts | 3 +- ...laude-structured-launch-resolution.test.ts | 26 +- .../claude-structured-launch-resolution.ts | 32 +- .../claude-structured-owner-identity.test.ts | 3 +- .../claude-structured-owner-identity.ts | 3 +- ...laude-structured-provider-fallback.test.ts | 3 +- .../claude-structured-real-cli-fold.test.ts | 3 +- .../claude/claude-structured-real-cli.test.ts | 13 +- .../claude-structured-resume-point.test.ts | 2 +- .../claude-structured-session-adapter.test.ts | 17 +- ...claude-structured-session-recovery.test.ts | 28 +- .../claude-structured-session-test-support.ts | 3 +- ...laude-structured-turn-resume-point.test.ts | 20 +- ...aude-subagent-resume-after-restart.test.ts | 3 +- .../codex-provider-retry-idle-sweep.test.ts | 3 +- ...-structured-acquisition-exit-proof.test.ts | 3 +- .../codex-structured-dispatch-test-support.ts | 3 +- ...x-structured-journal-goal-revision.test.ts | 3 +- ...tructured-journal-subagent-readers.test.ts | 3 +- ...journal-translation-turn-lifecycle.test.ts | 3 +- ...-journal-turn-settles-once-journal.test.ts | 3 +- ...codex-structured-launch-resolution.test.ts | 11 +- .../codex-structured-launch-resolution.ts | 3 +- .../codex-structured-owner-identity.test.ts | 3 +- .../codex/codex-structured-owner-identity.ts | 10 +- .../codex-structured-question-order.test.ts | 3 +- .../codex/codex-structured-session-acquire.ts | 10 +- ...odex-structured-session-adapter-fixture.ts | 3 +- ...ructured-session-adapter-lifecycle.test.ts | 3 +- .../codex-structured-session-adapter.test.ts | 7 +- ...tructured-session-background-tasks.test.ts | 3 +- .../codex-structured-session-cancel.test.ts | 3 +- .../codex-structured-session-close.test.ts | 3 +- .../codex-structured-session-shutdown.test.ts | 3 +- .../journal-corruption-repair.test.ts | 3 +- .../journal-crash-boundary.test.ts | 3 +- .../journal-dispatch-reason-bound.test.ts | 3 +- .../journal-epoch-replacement.test.ts | 5 +- .../journal-epoch-replacement.ts | 6 +- .../journal-epoch-rollover.ts | 6 +- .../journal-file-format-remnant.test.ts | 3 +- .../journal-host-database.test.ts | 5 +- .../journal-legacy-import.test.ts | 16 +- .../journal-per-session-import.test.ts | 3 +- .../journal-producer-inheritance.test.ts | 3 +- .../journal-restart-reconciliation.test.ts | 3 +- .../journal-row-builders.ts | 10 +- .../journal-row-context-usage.test.ts | 3 +- .../journal-row-schema-version.test.ts | 3 +- .../journal-row-schema.ts | 6 +- .../journal-stop-and-resume-rows.test.ts | 3 +- .../journal-store-schema.test.ts | 3 +- .../journal-store.test.ts | 3 +- .../agent-session-journal/journal-store.ts | 2 +- .../journal-subagent-liveness.test.ts | 3 +- .../journal-submission-queued-link.test.ts | 3 +- .../journal-unknown-row-kind.test.ts | 3 +- .../journal-write-queue.test.ts | 3 +- ...queued-message-bookkeeping-failure.test.ts | 3 +- .../queued-message-delivered-echo.test.ts | 3 +- .../queued-message-pause.test.ts | 3 +- .../queued-message-store.test.ts | 3 +- ...nt-session-history-byte-accounting.test.ts | 3 +- ...ession-history-conversation-window.test.ts | 3 +- ...ession-history-forward-read-budget.test.ts | 3 +- .../agent-session-history-page.test.ts | 3 +- .../agent-session-journal-recovery.test.ts | 14 +- .../agent-session-journal-recovery.ts | 12 +- ...-agent-session-accept-then-deliver.test.ts | 3 +- ...-agent-session-acquisition-options.test.ts | 7 +- ...tured-agent-session-adapter-router.test.ts | 5 +- ...tured-agent-session-adopted-import.test.ts | 3 +- ...structured-agent-session-adopted-import.ts | 11 +- ...ured-agent-session-append-delivery.test.ts | 3 +- ...red-agent-session-at-rest-commands.test.ts | 3 +- .../structured-agent-session-attach.ts | 40 +-- ...-agent-session-claude-option-queue.test.ts | 3 +- ...t-session-claude-stop-ends-session.test.ts | 2 +- ...ed-agent-session-conversation-stop.test.ts | 3 +- ...ctured-agent-session-conversations.test.ts | 3 +- ...tured-agent-session-crash-turn-end.test.ts | 9 +- ...session-dead-generation-settlement.test.ts | 5 +- ...tructured-agent-session-event-sink.test.ts | 3 +- ...ession-failed-create-owner-verdict.test.ts | 3 +- ...session-failed-create-sink-release.test.ts | 3 +- ...ctured-agent-session-forget-status.test.ts | 3 +- ...tured-agent-session-grouped-prompt.test.ts | 3 +- ...structured-agent-session-history-result.ts | 7 +- ...ed-agent-session-host-test-abandon.test.ts | 3 +- ...uctured-agent-session-host-test-harness.ts | 3 +- .../structured-agent-session-host.test.ts | 5 +- ...ured-agent-session-late-settlement.test.ts | 3 +- ...ctured-agent-session-lease-renewer.test.ts | 3 +- ...gent-session-legacy-handoff-record.test.ts | 3 +- ...ssion-main-agent-working-agreement.test.ts | 3 +- ...agent-session-operation-settlement.test.ts | 3 +- ...ed-agent-session-option-settlement.test.ts | 3 +- ...nt-session-processless-reservation.test.ts | 7 +- ...ctured-agent-session-prompt-cancel.test.ts | 3 +- ...gent-session-provider-child-record.test.ts | 5 +- ...red-agent-session-provider-restore.test.ts | 3 +- ...session-queued-message-rig.test-fixture.ts | 3 +- ...uctured-agent-session-read-restore.test.ts | 4 +- ...agent-session-recovered-turn-clock.test.ts | 3 +- ...tured-agent-session-recovery-exits.test.ts | 7 +- ...-agent-session-recovery-resolution.test.ts | 3 +- ...ctured-agent-session-refusal-retry.test.ts | 3 +- ...uctured-agent-session-repeat-press.test.ts | 3 +- ...ured-agent-session-resolved-append.test.ts | 3 +- .../structured-agent-session-rest-test-rig.ts | 3 +- ...ent-session-restart-resume-test-harness.ts | 8 +- ...session-restart-status-publication.test.ts | 3 +- ...ent-session-restore-without-import.test.ts | 3 +- .../structured-agent-session-reveal.test.ts | 3 +- ...tured-agent-session-rewind-at-rest.test.ts | 3 +- .../structured-agent-session-rewind.test.ts | 3 +- .../structured-agent-session-rewind.ts | 7 +- ...red-agent-session-send-idempotency.test.ts | 3 +- ...red-agent-session-send-preparation.test.ts | 3 +- ...session-send-restarts-failed-start.test.ts | 3 +- ...agent-session-settled-attach-retry.test.ts | 7 +- ...d-agent-session-stale-turn-verdict.test.ts | 11 +- ...agent-session-start-failure-writer.test.ts | 3 +- ...ent-session-status-feed-child-work.test.ts | 3 +- ...ed-agent-session-status-feed-child.test.ts | 3 +- ...agent-session-status-feed-children.test.ts | 3 +- ...ed-agent-session-status-feed-clock.test.ts | 3 +- ...ructured-agent-session-status-feed.test.ts | 3 +- ...tured-agent-session-status-reentry.test.ts | 3 +- ...red-agent-session-subagent-recency.test.ts | 3 +- ...ructured-agent-session-subscribers.test.ts | 24 +- ...red-agent-session-surface-lifetime.test.ts | 3 +- ...ructured-agent-session-thread-goal.test.ts | 3 +- .../structured-agent-session-turns.test.ts | 3 +- ...t-session-wedged-profile-migration.test.ts | 14 +- ...tured-agent-session-wire-admission.test.ts | 5 +- .../structured-conversation-command.test.ts | 20 +- ...structured-conversation-compaction.test.ts | 5 +- .../structured-provider-session-ownership.ts | 3 +- ...red-agent-session-history-adoption.test.ts | 12 +- ...ructured-agent-session-history-adoption.ts | 25 +- ...session-death-evidence-persistence.test.ts | 5 +- .../agent-session-lease-renewal.test.ts | 3 +- .../agent-session-lease-transitions.ts | 3 +- ...agent-session-legacy-record-import.test.ts | 28 +- .../agent-session-legacy-record-import.ts | 9 +- ...session-provider-handle-transition.test.ts | 43 ++- ...gent-session-provider-handle-transition.ts | 28 +- ...t-session-record-conversation-name.test.ts | 46 ++- .../agent-session-record-options.test.ts | 3 +- src/main/runtime/agent-session-record-rows.ts | 4 +- .../agent-session-record-store-file.ts | 4 +- ...agent-session-record-store-test-harness.ts | 26 +- .../agent-session-record-store.test.ts | 8 +- ...ession-released-reservation-replay.test.ts | 3 +- ...gent-session-reservation-admission.test.ts | 12 +- src/main/runtime/agent-session-store-draft.ts | 13 +- .../agent-session-store-transactions.test.ts | 3 +- ...sion-unsaved-creation-supersession.test.ts | 3 +- ...ude-structured-session-integration.test.ts | 14 +- .../structured-session-mail-address.ts | 6 +- .../structured-worker-journal-page.test.ts | 3 +- ...ured-agent-session-adoption-replay.test.ts | 3 +- .../structured-agent-session-hold.test.ts | 3 +- .../methods/structured-chat-tab-table.test.ts | 8 +- ...chestration-session-caller-test-fixture.ts | 7 +- ...untime-worktree-agent-rows-verdict.test.ts | 3 +- ...ree-structured-agent-rows-liveness.test.ts | 3 +- ...d-agent-session-integration-replay.test.ts | 3 +- ...ructured-agent-session-integration.test.ts | 5 +- ...gent-session-record-import-install.test.ts | 3 +- ...uctured-agent-session-runtime-exit.test.ts | 8 +- ...nt-session-spawn-token-descendants.test.ts | 3 +- ...-agent-session-startup-tab-restore.test.ts | 12 +- .../structured-chat-coordinator-mail.test.ts | 3 +- .../structured-claude-runtime-adapter.ts | 8 +- src/shared/agent-session-journal-types.ts | 13 +- ...agent-session-legacy-handoff-lease.test.ts | 16 +- .../agent-session-legacy-handoff-lease.ts | 19 +- .../agent-session-provider-handle-encoding.ts | 285 ++++++++++++++++++ .../agent-session-provider-handle.test.ts | 144 ++++++--- src/shared/agent-session-provider-handle.ts | 155 ++++++---- .../agent-session-record-stored-form.test.ts | 240 +++++++++++++++ .../agent-session-record-stored-form.ts | 41 +++ .../agent-session-record.test-fixture.ts | 3 +- src/shared/agent-session-record.ts | 15 +- ...x-child-approval-activity-row.unit.test.ts | 3 +- ...-session-stop-event-downgrade.unit.test.ts | 20 +- ...ss-version-agent-session-wire.unit.test.ts | 5 +- ...ured-agent-session-read-owner.unit.test.ts | 3 +- ...-chat-owner-status-activation.unit.test.ts | 3 +- 203 files changed, 1542 insertions(+), 632 deletions(-) create mode 100644 src/shared/agent-session-provider-handle-encoding.ts create mode 100644 src/shared/agent-session-record-stored-form.test.ts create mode 100644 src/shared/agent-session-record-stored-form.ts diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index ed3476702777..ecbc23538498 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -27,8 +27,10 @@ const identity = { workspaceId: 'fixture', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread' } + providerHandle: { transport: 'codex-app-server', agent: 'codex', nativeId: 'thread' } } +// The baseline predates the neutral handle and takes its journal identity in the typed form. +const baselineIdentity = { ...identity, providerHandle: { kind: 'codex', threadId: 'thread' } } const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-')) // Released in `finally`, newest first: an open SQLite handle blocks the fixture's removal on Windows. const releases = [] @@ -75,7 +77,10 @@ async function openArm(arm) { let journal let database if (arm === 'baseline') { - journal = await implementation.openAgentSessionJournal({ identity, journalDir: stateDirectory }) + journal = await implementation.openAgentSessionJournal({ + identity: baselineIdentity, + journalDir: stateDirectory + }) } else { database = implementation.JournalHostDatabase.open(stateDirectory) releases.push(() => database.close()) diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index b7104e50a0cd..d363e47627f2 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -11,6 +11,10 @@ import { assertLegacyAiVaultResumeCommandAllowed, projectStructuredAiVaultSessions } from './structured-session-ownership' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' @@ -93,6 +97,7 @@ function installOwnership(overrides: Partial ...overrides } const record = agentSessionRecordFixture(ownership.lease) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. setStructuredAgentSessionHost({ deps: { store: { @@ -105,7 +110,10 @@ function installOwnership(overrides: Partial providerHandleChain: [ { ...record.providerHandleChain[0]!, - handle: { provider: ownership.provider, threadId: ownership.providerSessionId } + handle: + ownership.provider === 'claude' + ? claudeProviderHandle(ownership.providerSessionId, null) + : codexProviderHandle(ownership.providerSessionId) } ], lease: { ...ownership.lease, sessionId: ownership.sessionId } diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index dcbd00776fda..695acefbb6f8 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -19,6 +19,7 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' 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' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -63,7 +64,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index 843fbb281d3d..f2933b8a702b 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -13,13 +13,14 @@ import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session- import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('provider-1', 'leaf-1') } /** A frame as Claude Code sends it while it retries a refused request. */ diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index be2c1569eadf..33ce088cca0f 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -11,6 +11,7 @@ import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { bindClaudeContextUsageCapture } from './claude-context-usage' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() let root: string @@ -104,7 +105,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) }, now: () => 9_000, stateDirectory: join(root, 'orca-session') diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index d98ef7583df5..5473719ce114 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -28,6 +28,7 @@ import { } from './claude-context-usage-test-support' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -49,7 +50,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) }, now: () => 9_000, stateDirectory: join(root, SESSION) diff --git a/src/main/claude/claude-informational-row.test.ts b/src/main/claude/claude-informational-row.test.ts index e41b71e1a2c1..b40d16b633bf 100644 --- a/src/main/claude/claude-informational-row.test.ts +++ b/src/main/claude/claude-informational-row.test.ts @@ -8,13 +8,14 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('provider-1', 'leaf-1') } /** A frame as Claude Code 2.1.280 sends it: a transcript note at a render level. */ diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 082ed4c12ce7..bd79a4a2c9d2 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -22,6 +22,7 @@ import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // These drive the real SDK against the scripted fake CLI, so every assertion is // about the environment, argv and frames a real child actually saw. @@ -380,7 +381,7 @@ describe('Claude stream-json connection', () => { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle(SESSION_ID, 'leaf-1') }, database: openTestJournalHostDatabase(join(scenario.cwd, 'journal')), now: () => 1_700_000_000_000, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index b3764b16d99e..4de526c7c4db 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -1,3 +1,4 @@ +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, @@ -72,11 +73,10 @@ export async function resolveClaudeAcquisitionLaunch(args: { const launchIdentity = resumeSession ? { ...input.identity, - providerHandle: { - kind: 'claude' as const, - sessionId: resumeSession.providerSessionId, - leafUuid: resumeSession.turnEndLeafUuid - } + providerHandle: claudeProviderHandle( + resumeSession.providerSessionId, + resumeSession.turnEndLeafUuid + ) } : input.identity const launch = await deps diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index 002240e4dd77..aa535a12a321 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -15,6 +15,7 @@ import { fakeClaude, identityFor } from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-auth' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -60,11 +61,10 @@ function realResolverAdapter( claude: ReturnType, authSwitchSettleTimeoutMs: number ): ClaudeStructuredSessionAdapter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only the chain head's handle from this partial record. const resumable = { ...record(), - providerHandleChain: [ - { handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } } - ] + providerHandleChain: [{ handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }] } as unknown as AgentSessionRecord return new ClaudeStructuredSessionAdapter({ resolveLaunch: createClaudeStructuredLaunchResolver({ diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts index 851831b86f19..e5ed29561cbc 100644 --- a/src/main/claude/claude-structured-history-window.test.ts +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -13,6 +13,7 @@ import { claudeProviderHistoryWindowFromJsonl, resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const PROVIDER_SESSION = 'provider-1' const ORCA_SESSION = 'session-1' @@ -82,7 +83,7 @@ describe('claudeProviderHistoryWindowFromJsonl', () => { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: 'anchor' } + providerHandle: claudeProviderHandle(PROVIDER_SESSION, 'anchor') }, accountHomePath: accountHome, hasLiveSession: false diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts index 00ae86920251..fc5c661a6906 100644 --- a/src/main/claude/claude-structured-history-window.ts +++ b/src/main/claude/claude-structured-history-window.ts @@ -16,6 +16,11 @@ import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + claudeProviderHandleLeafUuid, + isAgentSessionProviderHandleInNamespace +} from '../../shared/agent-session-provider-handle-encoding' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import type { ProviderHistoryItem, @@ -242,10 +247,13 @@ export async function resolveClaudeProviderHistoryWindow(input: { hasLiveSession: boolean }): Promise { const handle = input.identity.providerHandle - if (handle.kind !== 'claude') { + if ( + !handle || + !isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) + ) { return null } - const transcriptPath = await resolveSessionFilePath('claude', handle.sessionId, { + const transcriptPath = await resolveSessionFilePath('claude', handle.nativeId, { claudeProjectsDir: join(input.accountHomePath, 'projects') }) if (!transcriptPath) { @@ -253,8 +261,8 @@ export async function resolveClaudeProviderHistoryWindow(input: { } return readClaudeProviderHistoryWindow({ transcriptPath, - providerSessionId: handle.sessionId, - previousLeafUuid: handle.leafUuid, + providerSessionId: handle.nativeId, + previousLeafUuid: claudeProviderHandleLeafUuid(handle), sessionId: input.identity.sessionId, turnInFlight: input.hasLiveSession }) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 0cf7b30368a7..4aa02ed525cb 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -24,6 +24,7 @@ import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' function sinkState() { const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] @@ -180,7 +181,7 @@ const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('claude-session', 'leaf-1') } let journalRoot = '' diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 66fed64c3230..9db732d3430d 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -16,6 +16,7 @@ import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -41,7 +42,7 @@ function record(overrides: Partial = {}): AgentSessionRecord function identityAt(leafUuid: string | null): typeof IDENTITY { return { ...IDENTITY, - providerHandle: { kind: 'claude', sessionId: 'provider-current', leafUuid } + providerHandle: claudeProviderHandle('provider-current', leafUuid) } } @@ -100,8 +101,9 @@ const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { } const RESUMABLE = record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } } + { handle: claudeProviderHandle('provider-current', 'leaf-current') } ] as AgentSessionRecord['providerHandleChain'] }) @@ -136,14 +138,11 @@ describe('claude structured launch resolution', () => { it('resumes the durable chain head by session id and carries its leaf as bookkeeping', async () => { const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } }, + { handle: claudeProviderHandle('provider-old', 'leaf-old') }, { - handle: { - provider: 'claude', - sessionId: 'provider-current', - leafUuid: 'leaf-current' - } + handle: claudeProviderHandle('provider-current', 'leaf-current') } ] as AgentSessionRecord['providerHandleChain'] }) @@ -194,7 +193,7 @@ describe('claude structured launch resolution', () => { resolve({ identity: { ...IDENTITY, - providerHandle: { kind: 'claude', sessionId: 'provider-other', leafUuid: 'leaf-current' } + providerHandle: claudeProviderHandle('provider-other', 'leaf-current') } }) ).rejects.toThrow('durable resume identity changed before spawn') @@ -203,13 +202,10 @@ describe('claude structured launch resolution', () => { it('keeps session-only resume when the durable handle has no leaf', async () => { const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ { - handle: { - provider: 'claude', - sessionId: 'provider-current', - leafUuid: null - } + handle: claudeProviderHandle('provider-current', null) } ] as AgentSessionRecord['providerHandleChain'] }) @@ -226,7 +222,7 @@ describe('claude structured launch resolution', () => { record({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle. providerHandleChain: [ - { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: null } } + { handle: claudeProviderHandle('provider-current', null) } ] as AgentSessionRecord['providerHandleChain'] }), undefined, diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 29703dfd8eb7..979b5ae5963e 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -5,7 +5,11 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleRoot +} from '../../shared/agent-session-provider-handle' +import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' @@ -260,24 +264,25 @@ export function createClaudeStructuredLaunchResolver( gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } - const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + // A Claude record's chain holds only Claude handles; the record store refuses anything else. + const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null if ( - head?.handle.provider === 'claude' && - (identity.providerHandle.kind !== 'claude' || - identity.providerHandle.sessionId !== head.handle.sessionId) + head && + (!identity.providerHandle || + agentSessionProviderHandleRoot(identity.providerHandle) !== + agentSessionProviderHandleRoot(head)) ) { throw new Error('claude durable resume identity changed before spawn') } - const providerSessionId = - head?.handle.provider === 'claude' - ? head.handle.sessionId - : claudeSessionIdForOrcaSession(identity.sessionId) - const continuesChain = head?.handle.provider === 'claude' + const providerSessionId = head + ? head.nativeId + : claudeSessionIdForOrcaSession(identity.sessionId) + const continuesChain = head !== null // A start that failed before its first turn wrote no transcript, and `--resume` of an absent // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. const resumesTranscript = - head?.handle.provider === 'claude' && - (head.handle.leafUuid !== null || + head !== null && + (claudeProviderHandleLeafUuid(head) !== null || (await (deps.hasTranscript ?? claudeTranscriptExists)({ providerSessionId, claudeConfigDir: record.accountHome.path @@ -308,8 +313,7 @@ export function createClaudeStructuredLaunchResolver( env, claudeConfigDir: record.accountHome.path, providerSessionId, - resumeLeafUuid: - resumesTranscript && head?.handle.provider === 'claude' ? head.handle.leafUuid : null, + resumeLeafUuid: resumesTranscript && head ? claudeProviderHandleLeafUuid(head) : null, resumesTranscript, continuesChain } diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts index 592b61df3380..729aecbec877 100644 --- a/src/main/claude/claude-structured-owner-identity.test.ts +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY = { sessionId: 'session-identity', workspaceId: 'workspace-1', hostId: 'local', agent: 'claude' as const, - providerHandle: { kind: 'claude' as const, sessionId: 'session-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('session-1', 'leaf-1') } describe('claude structured owner identity', () => { diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index e8f251d41f91..80f6ca6977dc 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -1,5 +1,6 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' @@ -16,7 +17,7 @@ export function claudeProviderHandleLink(input: { linkId: input.linkId ?? `claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128), - handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid }, + handle: claudeProviderHandle(input.sessionId, input.leafUuid), origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), mintedAtFence: input.fence, observedAt: input.observedAt diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts index 4cae8b465e99..3e67d35f7b48 100644 --- a/src/main/claude/claude-structured-provider-fallback.test.ts +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -12,13 +12,14 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } + providerHandle: claudeProviderHandle('provider-1', 'leaf-1') } let root = '' diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 5ce21d83f170..13e4ee5e6720 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -27,6 +27,7 @@ import { realClaudeCommand, realClaudeLaunchHome } from './claude-real-cli-availability-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'real-cli-fold' // Pins the live proof order (SessionStart hook frame before system/init) and lets the @@ -42,7 +43,7 @@ function identity(providerSessionId: string): AgentSessionJournalIdentity { workspaceId: 'real-cli-fold-workspace', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + providerHandle: claudeProviderHandle(providerSessionId, null) } } diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index bd88413edba7..bd7d8b9eb63b 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -18,6 +18,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand const suiteTitle = `Claude structured real CLI handshake${realClaudeCliGate.skipReason ? ` (skipped: ${realClaudeCliGate.skipReason})` : ''}` @@ -61,7 +62,7 @@ function identity(providerSessionId: string): AgentSessionJournalIdentity { workspaceId: 'real-cli-workspace', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + providerHandle: claudeProviderHandle(providerSessionId, null) } } @@ -106,13 +107,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { event.type === 'message' ? [event.message.subtype] : [] ) - expect(acquisition.link.handle).toMatchObject({ - provider: 'claude', - sessionId: providerSessionId, - // Init/SessionStart UUIDs are protocol frames, not resumable - // main-transcript leaves; no cursor exists before the first user turn. - leafUuid: null - }) + // Init/SessionStart UUIDs are protocol frames, not resumable + // main-transcript leaves; no cursor exists before the first user turn. + expect(acquisition.link.handle).toEqual(claudeProviderHandle(providerSessionId, null)) expect(observedSubtypes).toContain('hook_started') expect(adapter.readCommands('real-cli-handshake')).toContainEqual( expect.objectContaining({ diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 862909a2cd66..0b68903c618d 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -88,7 +88,7 @@ describe('Claude resume point is the last completed turn on every exit path', () spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link.handle).toMatchObject({ leafUuid: 'a3' }) + expect(acquisition.link.handle).toMatchObject({ providerData: 'a3' }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persisted).toEqual([expect.objectContaining({ leafUuid: 'a3' })]) }) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index ee14b6b525a8..cd5b4841de6d 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -24,6 +24,7 @@ import { type FakeConnection } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter.acquire', () => { it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { @@ -57,7 +58,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) expect(acquisition.link).toEqual({ linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION_ID, null), origin: 'created', mintedAtFence: 7, observedAt: 1_700_000_000_500 @@ -424,11 +425,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9' }) - expect(acquisition.link.handle).toEqual({ - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: null - }) + expect(acquisition.link.handle).toEqual(claudeProviderHandle(PROVIDER_SESSION_ID, null)) expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } @@ -476,11 +473,9 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9' }) expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual({ - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'leaf-before' - }) + expect(acquisition.link.handle).toEqual( + claudeProviderHandle(PROVIDER_SESSION_ID, 'leaf-before') + ) const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) await expect(endedAtStartup(wrongClaude)).resolves.toMatchObject({ diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 71a7ac9ce319..2fd59d1a9e57 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -13,6 +13,10 @@ import { tick } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' +import { + claudeProviderHandle, + claudeProviderHandleLeafUuid +} from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { it('shares concurrent close finalization and emits lifecycle once', async () => { @@ -289,9 +293,9 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { let durableLeafUuid: string | null = null const resolveLaunch = vi.fn(async ({ identity }) => { if ( - identity.providerHandle.kind !== 'claude' || - identity.providerHandle.sessionId !== PROVIDER_SESSION_ID || - identity.providerHandle.leafUuid !== durableLeafUuid + !identity.providerHandle || + identity.providerHandle.nativeId !== PROVIDER_SESSION_ID || + claudeProviderHandleLeafUuid(identity.providerHandle) !== durableLeafUuid ) { throw new Error('claude durable resume identity changed before spawn') } @@ -378,11 +382,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const replacement = await adapter.acquire({ identity: { ...identityFor(), - providerHandle: { - kind: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf' - } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') }, fence: 8, spawnToken: 'spawn-10', @@ -404,11 +404,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { expect(resolveLaunch).toHaveBeenNthCalledWith(2, { identity: { ...identityFor(), - providerHandle: { - kind: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf' - } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') } }) expect(oldPrompt.settled()).toBe(true) @@ -425,11 +421,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { } ]) expect(replacement.link).toMatchObject({ - handle: { - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - leafUuid: 'observed-retained-leaf' - }, + handle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf'), origin: 'resumed', mintedAtFence: 8 }) diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index dd7ad1e8d8c5..02cf46aadc83 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -15,6 +15,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -30,7 +31,7 @@ export function identityFor(sessionId = 'session-1'): AgentSessionJournalIdentit workspaceId: 'workspace-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, null) } } diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index 4cb72eaa53a6..fd6790b2deb8 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -6,7 +6,7 @@ import { import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { reviseAgentSessionClaudeResumePoint } from '../runtime/agent-session-provider-handle-transition' +import { reviseAgentSessionProviderResumePoint } from '../runtime/agent-session-provider-handle-transition' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' import { ClaudeStructuredSessionAdapter, @@ -20,6 +20,7 @@ import { recordingJournalSink, tick } from './claude-structured-session-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const FENCE = 7 @@ -36,14 +37,14 @@ function liveRecord(): AgentSessionRecord { origin: 'created', mintedAtFence: 1, observedAt: 500, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + handle: claudeProviderHandle(PROVIDER_SESSION_ID, null) }, { linkId: 'published-link', origin: 'resumed', mintedAtFence: FENCE, observedAt: 1_000, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: 'resumed-at' } + handle: claudeProviderHandle(PROVIDER_SESSION_ID, 'resumed-at') } ] } @@ -78,11 +79,10 @@ async function liveOwner( persistResumePoint: persistResumePoint ?? (async ({ providerSessionId, leafUuid, fence }) => { - store.record = reviseAgentSessionClaudeResumePoint({ + store.record = reviseAgentSessionProviderResumePoint({ record: store.record, fence, - providerSessionId, - leafUuid, + handle: claudeProviderHandle(providerSessionId, leafUuid), now: 2_000 }) }) @@ -115,12 +115,12 @@ describe('Claude durable resume point at turn end', () => { const { adapter, store, turn } = await liveOwner() try { await turn('u1', 'a1') - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'a1' }) + expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'a1' }) await turn('u2', 'a2') expect(store.record.providerHandleChain).toHaveLength(2) expect(store.record.providerHandleChain.at(-1)).toMatchObject({ linkId: 'published-link', - handle: { leafUuid: 'a2' } + handle: { providerData: 'a2' } }) } finally { await adapter.closeAll() @@ -135,7 +135,7 @@ describe('Claude durable resume point at turn end', () => { frame({ type: 'user', uuid: 'u3' }) await tick() const head = store.record.providerHandleChain.at(-1)!.handle - expect(head).toMatchObject({ leafUuid: 'a2' }) + expect(head).toMatchObject({ providerData: 'a2' }) const resolve = createClaudeStructuredLaunchResolver({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only getRecord from its store. @@ -147,7 +147,7 @@ describe('Claude durable resume point at turn end', () => { const launch = await resolve({ identity: { ...identityFor(store.record.sessionId), - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: 'a2' } + providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'a2') } }) // Bookkeeping only: Claude continues from the end of its own conversation. diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index c4b848eee44c..d95dd3a48ffc 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -17,6 +17,7 @@ import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/ag import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // Frame orders are real sessions', scrubbed. A resumed agent's frames still name its ORIGINAL // spawn call while the announcement names the message call that resumed it, and a new provider @@ -129,7 +130,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) }, now: () => 9_000, stateDirectory: root diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 0e3e8385685e..722858828f25 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -19,6 +19,7 @@ import { import { createCodexJournalTranslator } from './codex-structured-journal-translation' 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' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -61,7 +62,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts index f0737fc36a36..f86d0aff6c1f 100644 --- a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts +++ b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts @@ -3,13 +3,14 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } describe('Codex failed-acquisition exit proof', () => { diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 7513ec1fbbe4..9ac0f7f31bd9 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -14,6 +14,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export const CODEX_TEST_THREAD_ID = 'thread-abc' @@ -114,7 +115,7 @@ export async function acquiredCodexAdapter(input: { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID } + providerHandle: codexProviderHandle(CODEX_TEST_THREAD_ID) } await adapter.acquire({ identity, diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index aa455403ea78..11b559655b4d 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -15,6 +15,7 @@ import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' const IDENTITY: AgentSessionJournalIdentity = { @@ -22,7 +23,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } let root: string | null = null diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index a99b815bff48..87b888ec1a08 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -22,6 +22,7 @@ import { createCodexJournalTranslator } from './codex-structured-journal-transla import type { CodexThreadItem } from './codex-thread-item-identity' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-codex-children' const PARENT = 'thread-parent' @@ -43,7 +44,7 @@ async function openJournal(root: string): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: PARENT } + providerHandle: codexProviderHandle(PARENT) }, database: openTestJournalHostDatabase(root), now: () => 1_000 diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 07115c4b4e77..0f51961b64d7 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -27,6 +27,7 @@ import { createCodexStructuredNotificationRetry } from './codex-structured-notif import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSession } from './codex-structured-session-state' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-abc' @@ -219,7 +220,7 @@ describe('codex turn lifecycle rows', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) }, now: () => 9_000, stateDirectory: join(root, SESSION_ID) diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index da24f836d1a7..05748ead80f7 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -14,6 +14,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-codex-failed-turn' const THREAD = 'thread-abc' @@ -35,7 +36,7 @@ async function session() { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 36ab39e3babc..1e6a6b4805e5 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -5,6 +5,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< @@ -105,9 +106,10 @@ describe('codex structured launch resolution', () => { it('resumes the last thread this session actually proved, not one a caller names', async () => { const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'codex', threadId: 'thread-old' } }, - { handle: { provider: 'codex', threadId: 'thread-current' } } + { handle: codexProviderHandle('thread-old') }, + { handle: codexProviderHandle('thread-current') } ] as AgentSessionRecord['providerHandleChain'] }) )({ identity: IDENTITY }) @@ -121,7 +123,7 @@ describe('codex structured launch resolution', () => { mintedAtFence: number ): AgentSessionProviderHandleLink => ({ linkId: `link-${mintedAtFence}`, - handle: { provider: 'codex', threadId: 't' }, + handle: codexProviderHandle('t'), origin, mintedAtFence, observedAt: 1 @@ -204,8 +206,9 @@ describe('codex structured launch resolution', () => { const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl') const launch = await resolverFor( record({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset. providerHandleChain: [ - { handle: { provider: 'codex', threadId: 'thread-current' } } + { handle: codexProviderHandle('thread-current') } ] as AgentSessionRecord['providerHandleChain'] }), async (id) => `/repos/${id}`, diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index ebfdb27200a5..19945c9a6c89 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -93,7 +93,8 @@ 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) - const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null + // A Codex record's chain holds only Codex handles; the record store 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 return { diff --git a/src/main/codex/codex-structured-owner-identity.test.ts b/src/main/codex/codex-structured-owner-identity.test.ts index c86885171e47..229da5e00bd1 100644 --- a/src/main/codex/codex-structured-owner-identity.test.ts +++ b/src/main/codex/codex-structured-owner-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const IDENTITY = { sessionId: 'session-identity', workspaceId: 'workspace-1', hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } describe('codex process identity', () => { diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 805a767e6200..9b0d3e8e5488 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -3,6 +3,7 @@ import { agentSessionProviderHandleKey, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' @@ -91,16 +92,15 @@ export function codexProviderHandleLink( ): AgentSessionProviderHandleLink { return { linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128), - handle: { provider: 'codex', threadId: input.threadId }, + handle: codexProviderHandle(input.threadId), origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), mintedAtFence: input.fence, observedAt: input.observedAt, ...(input.supersedesThreadId ? { - supersedesKey: agentSessionProviderHandleKey({ - provider: 'codex', - threadId: input.supersedesThreadId - }) + supersedesKey: agentSessionProviderHandleKey( + codexProviderHandle(input.supersedesThreadId) + ) } : {}) } diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 8ad4b75bd38e..388a174fad7c 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -37,6 +37,7 @@ import { } from '../../renderer/src/components/native-chat/structured-agent-question-projection' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: HOST_TEST_NOW diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a1..51dee838147b 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -1,3 +1,7 @@ +import { + CODEX_STRUCTURED_HANDLE_NAMESPACE, + isAgentSessionProviderHandleInNamespace +} from '../../shared/agent-session-provider-handle-encoding' import { AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, @@ -71,9 +75,11 @@ export async function acquireCodexStructuredSession(input: { const { previousAttempt, attempt } = acquisitions.start(sessionId) const acquisition = attempt.window let unbindReadingControl: (() => void) | undefined + const provenHandle = acquireInput.identity.providerHandle let primaryThreadId = - acquireInput.identity.providerHandle.kind === 'codex' - ? acquireInput.identity.providerHandle.threadId + provenHandle && + isAgentSessionProviderHandleInNamespace(provenHandle, CODEX_STRUCTURED_HANDLE_NAMESPACE) + ? provenHandle.nativeId : null const subagentExecutions = new CodexSubagentExecutions() const dispatchEchoes = createCodexDispatchEchoes() diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index f2b4e102c355..ee174e822a21 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -14,6 +14,7 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export const THREAD_ID = 'thread-abc' @@ -23,7 +24,7 @@ export function identityFor(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index e3211f320e85..8bc3c8611c61 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -16,6 +16,7 @@ import { type CodexStructuredLaunch, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD_ID = 'thread-abc' @@ -25,7 +26,7 @@ function identityFor(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147a..621c453b0572 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -20,6 +20,7 @@ import { fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('CodexStructuredSessionAdapter.acquire', () => { it('starts a new thread and reports the process and link the lease will prove', async () => { @@ -55,7 +56,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) expect(acquisition.link).toEqual({ linkId: `codex-7-${THREAD_ID}`, - handle: { provider: 'codex', threadId: THREAD_ID }, + handle: codexProviderHandle(THREAD_ID), origin: 'created', mintedAtFence: 7, observedAt: 1_700_000_000_500 @@ -99,7 +100,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) }) expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' }) + expect(acquisition.link.handle).toEqual(codexProviderHandle('thread-proven')) }) it('starts a thread in place of a creation Codex never saved, and says which it replaced', async () => { @@ -129,7 +130,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ]) expect(acquisition.link).toEqual({ linkId: `codex-9-${THREAD_ID}`, - handle: { provider: 'codex', threadId: THREAD_ID }, + handle: codexProviderHandle(THREAD_ID), origin: 'created', supersedesKey: 'codex:"thread-unsaved"', mintedAtFence: 9, diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index 4e2515da7d43..466cadce8a13 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -10,6 +10,7 @@ import { CodexStructuredSessionAdapter } from './codex-structured-session-adapte import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // Proves the host's child records are actually REACHED from provider traffic: the tracker is // unit-tested separately, and a producer that is correct but unwired publishes nothing while @@ -51,7 +52,7 @@ function identity(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d1..49112d6b862e 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -47,6 +47,7 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD_ID = 'thread-abc' const USER_MESSAGE: AgentJournalMessageItem = { @@ -69,7 +70,7 @@ function identity(): AgentSessionJournalIdentity { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 9d80a0fa9829..cb443a157e02 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -17,6 +17,7 @@ import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' @@ -26,7 +27,7 @@ function identity(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } } diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts index 3883dad4600e..44ed5c93d411 100644 --- a/src/main/codex/codex-structured-session-shutdown.test.ts +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -8,6 +8,7 @@ import { CodexStructuredSessionAdapter, type CodexStructuredLaunch } from './codex-structured-session-adapter' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'session-1' const THREAD_ID = 'thread-1' @@ -25,7 +26,7 @@ function identity(): AgentSessionJournalIdentity { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD_ID } + providerHandle: codexProviderHandle(THREAD_ID) } } diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index 8c4db243aa79..899a3ff7bcc7 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -28,13 +28,14 @@ import { updateTestJournalRowJson, deleteTestJournalRow } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index a6821cf8427b..278fea939737 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -26,13 +26,14 @@ import { type ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const TURN_ID = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index cefd4bbe368e..574dec492ee9 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -11,13 +11,14 @@ import { import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 2992459a787a..6337b00417bf 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -19,19 +19,20 @@ import { openTestJournalHostDatabase, readTestJournalRows } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const PEER: AgentSessionJournalIdentity = { ...IDENTITY, sessionId: 'session-peer', - providerHandle: { kind: 'codex', threadId: 'thread-peer' } + providerHandle: codexProviderHandle('thread-peer') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 70efc8912837..b2754e33acaf 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -11,6 +11,7 @@ import type { AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' @@ -58,7 +59,10 @@ export function replaceJournalEpoch(input: { const epochRow: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.identity.providerHandle, + providerHandle: agentSessionJournalProviderHandle( + input.identity.providerHandle, + input.identity.agent + ), ...journalRowBase(epoch, 1, input.fence, input.now()) } const rows: JournalRow[] = [epochRow] diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 855f60ac3261..26296f89d109 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -7,6 +7,7 @@ import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' @@ -32,7 +33,10 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.identity.providerHandle, + providerHandle: agentSessionJournalProviderHandle( + input.identity.providerHandle, + input.identity.agent + ), // Carries no body: an older host must keep reading a turn-free session past row 1. v: journalRowSchemaVersion([]), epoch: input.epoch, diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index ed27ed6fa0b2..bd3dff1fb656 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -23,13 +23,14 @@ import { deleteTestJournalRow, insertTestJournalRowJson } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 84ff9775022b..ba2819094ad4 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -20,19 +20,20 @@ import { readTestJournalRows } from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const OTHER: AgentSessionJournalIdentity = { ...IDENTITY, sessionId: 'session-2', - providerHandle: { kind: 'codex', threadId: 'thread-2' } + providerHandle: codexProviderHandle('thread-2') } const SUBMISSION = { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index a38a37fd0c82..9599fa33c5be 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -8,10 +8,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentSessionJournalIdentity, - AgentSessionProviderHandle -} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' import type { NativeChatMessage } from '../../../shared/native-chat-types' import type * as TranscriptLineDecoders from '../transcript-line-decoders' import { createLegacyIdentityTracker } from './journal-legacy-identity' @@ -20,6 +18,10 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' import { openTestJournalHostDatabase } from './journal-host-database-test-support' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' // No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. const decodedClaudeOverride = vi.hoisted((): { message: NativeChatMessage | null } => ({ @@ -50,11 +52,11 @@ type ImportAgent = 'claude' | 'codex' | 'grok' | 'omp' function providerHandle(agent: ImportAgent, sessionId: string): AgentSessionProviderHandle { if (agent === 'claude') { - return { kind: 'claude', sessionId, leafUuid: null } + return claudeProviderHandle(sessionId, null) } return agent === 'codex' - ? { kind: 'codex', threadId: sessionId } - : { kind: 'opaque', agent, value: sessionId } + ? codexProviderHandle(sessionId) + : { transport: 'terminal-transcript', agent, nativeId: sessionId } } function identity(agent: ImportAgent, sessionId: string): AgentSessionJournalIdentity { diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index ebe2da08a55b..e80ac7acf90c 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -29,6 +29,7 @@ import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -40,7 +41,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index d41e9b363ed2..8dea06cebee6 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -16,6 +16,7 @@ import { journalLifecycleBatchRowBuilder } from './journal-row-builders' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // A row's producer is fixed by the write that created it. A revision naming no // producer — a settlement, a prompt answer, a reopen sweep — keeps it; a @@ -170,7 +171,7 @@ describe('producer inheritance across a reopen', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'root' } + providerHandle: codexProviderHandle('root') }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 2f28adf0d545..8e541f110b00 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -19,13 +19,14 @@ import { createTrackedJournalOpener } from './journal-host-database-test-support import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } + providerHandle: claudeProviderHandle('provider-1', null) } function claudeIdentity(uuid: string): AgentJournalItemIdentity { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..2cf3f60b0e6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -5,8 +5,10 @@ import type { AgentJournalProducerLinkage, AgentJournalRowAttribution, AgentJournalTurnScope, - AgentSessionProviderHandle + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' +import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields, @@ -63,7 +65,7 @@ export function journalTombstoneRowBuilder( export function journalSubmissionRowBuilder( state: () => JournalReducerState, - providerHandle: AgentSessionProviderHandle, + identity: Pick, input: { clientMessageId: string payloadFingerprint: string @@ -85,7 +87,7 @@ export function journalSubmissionRowBuilder( const queuedMessageId = consume?.messageId ?? input.queuedMessageId return buildJournalSubmissionRow({ state: state(), - providerHandle, + providerHandle: agentSessionJournalProviderHandle(identity.providerHandle, identity.agent), ...input, ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), seq, @@ -300,7 +302,7 @@ export function buildJournalSubmissionRow(input: { state: JournalReducerState clientMessageId: string payloadFingerprint: string - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle body: AgentJournalMessageItem seq: number fence: number diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index 623c97445a61..83bb81851fca 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -19,13 +19,14 @@ import { liveTestJournalRows, updateTestJournalRowJson } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + providerHandle: claudeProviderHandle('claude-session', null) } const USAGE = { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index 616f4b557562..4fdaaa880b12 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -9,6 +9,7 @@ import { openTestJournalHostDatabase, liveTestJournalRows } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // Which rows an older host can still read: only rows that carry a turn item // are stamped with the version it does not know, and the epoch row never is. @@ -33,7 +34,7 @@ describe('journal row schema versions', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, now: () => 1_000, stateDirectory: join(root, 'session-1') diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..8df9492beb7f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -21,7 +21,7 @@ import { type AgentJournalMessageItem, type AgentJournalProducerLinkage, type AgentJournalTurnScope, - type AgentSessionProviderHandle + type AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' import { isAdmissibleAgentJournalItemBody, @@ -57,7 +57,7 @@ type JournalRowBase = AgentJournalProducerLinkage & { export type JournalEpochRow = JournalRowBase & { kind: 'epoch' reason: AgentJournalEpochReason - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle } export const AGENT_JOURNAL_EPOCH_REASONS = [ @@ -121,7 +121,7 @@ export type JournalSubmissionRow = JournalRowBase & { kind: 'submission' clientMessageId: string payloadFingerprint: string - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle body: AgentJournalMessageItem /** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer * dispatched in the same step. Older readers keep the key and ignore it. */ diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index 8eee42a70d03..a7fedb6a0a10 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -23,13 +23,14 @@ import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-s', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const EPOCH = 'epoch-1' diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index d7c5e521c821..d9b59539bd03 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -27,13 +27,14 @@ import { liveTestJournalRows, insertTestJournalRowJson } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 871aca7880dc..f885ee94543c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -29,13 +29,14 @@ import { deleteTestJournalRow } from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155dec..5be970df78d0 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -343,7 +343,7 @@ export class AgentSessionJournal { consume?: JournalSubmissionConsume ): Promise { return this.rowWriter.append( - journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), + journalSubmissionRowBuilder(() => this.state, this.identity, input, consume), consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index 6b519ce35cd8..4da76e0ba429 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -21,13 +21,14 @@ import { import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const GROUP_ID = 'thread-1:turn-1' diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index fc9431646158..1159a37ef33f 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -15,13 +15,14 @@ import { createJournalReducerState, applyJournalRow } from './journal-reducer' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const BODY: AgentJournalMessageItem = { kind: 'message', diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index 794ca52ab7d5..55a8a0460858 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -20,13 +20,14 @@ import { openTestJournalHostDatabase } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-newer-kind', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 3cdd554cc4e8..9b5653b060f8 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -20,13 +20,14 @@ import { openJournalOwingImport } from './journal-owed-import-test-support' import type { AgentSessionJournal } from './journal-store' import { openAgentSessionJournal } from './journal-store-factory' import { JournalWriteQueue } from './journal-write-queue' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } as const const reply = (text: string): AgentJournalMessageItem => ({ diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 638a62bdf02a..3dfa581c70d4 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -20,13 +20,14 @@ import { closeTestJournalHostDatabases, createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const REFUSAL = agentSessionFailureWords( agentSessionFailureFact('providerRejected', { diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 4ce62c6192b2..a12630d6566d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -17,13 +17,14 @@ import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent import { JournalQueuedMessages } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index b8e45e0ef590..a2fa19a6a5c3 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -29,13 +29,14 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-p', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const HOST = 'proc-1' diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index c3910e466854..8a627834e285 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -26,13 +26,14 @@ import { closeTestJournalHostDatabases, createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } let root: string diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 7711b670438a..115e2248ea0f 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -11,13 +11,14 @@ import type { import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts index 8438b40d1224..9cbfb94a9079 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts @@ -19,6 +19,7 @@ import { readAgentSessionHistory, readAgentSessionHydrationPage } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // A backward page windows over the session's own rows, and a subagent's rows in // that range ride along, so a burst cannot crowd the conversation off the page. @@ -28,7 +29,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 8abfce258b85..1a1e7fda2cd8 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -20,13 +20,14 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { readAgentSessionHistory } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const identity: AgentSessionJournalIdentity = { sessionId: 'bounded-catch-up', workspaceId: 'folder-workspace', hostId: 'remote-host', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() let root: string | undefined diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index e40ebcf326ef..de31297ee254 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -35,13 +35,14 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { projectJournalBatch } from './agent-session-journal-batch' import { readAgentSessionHistory, resolveHistoryLimit } from './agent-session-history-page' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 52fc3c86b9b6..4e4de1300eae 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -33,6 +33,10 @@ import { } from './agent-session-journal-recovery' import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' // The store's replay goes through the mock: it is the only read of the journal an open makes. vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { @@ -47,7 +51,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_SESSION } + providerHandle: codexProviderHandle(CODEX_SESSION) } const CODEX_LINES = [ @@ -156,10 +160,8 @@ afterEach(async () => { describe('providerHistoryId', () => { it('uses the provider handle, never the Orca session id', () => { - expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') - expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( - 'sess-9' - ) + expect(providerHistoryId(codexProviderHandle('thread-9'))).toBe('thread-9') + expect(providerHistoryId(claudeProviderHandle('sess-9', null))).toBe('sess-9') }) }) @@ -311,7 +313,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const neighbour: AgentSessionJournalIdentity = { ...IDENTITY, sessionId: 'neighbour-session', - providerHandle: { kind: 'codex', threadId: 'neighbour-thread' } + providerHandle: codexProviderHandle('neighbour-thread') } const other = await journals.open({ identity: neighbour, stateDirectory: journalDir }) for (let ordinal = 1; ordinal <= 3; ordinal += 1) { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 42da9a986dad..4f75fdec3755 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -8,9 +8,10 @@ import type { AgentType } from '../../../shared/agent-status-types' import type { AgentJournalResetReason, - AgentSessionJournalIdentity, - AgentSessionProviderHandle + AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' +import { AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -34,11 +35,8 @@ export type AgentSessionJournalOpened = { /** The provider's own session id, which is what the transcript readers index * by — never the Orca session id. */ -export function providerHistoryId(handle: AgentSessionProviderHandle): string { - if (handle.kind === 'codex') { - return handle.threadId - } - return handle.kind === 'claude' ? handle.sessionId : handle.value +export function providerHistoryId(handle: AgentSessionProviderHandle | null): string { + return handle?.nativeId ?? AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } export async function openAgentSessionJournalWithRecovery(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b3f91aea7002..7cc6e05a6a9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -46,6 +46,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -66,7 +67,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), 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 c98854292d5c..197585c42b59 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 @@ -21,6 +21,7 @@ import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -90,7 +91,7 @@ function adapter(input: { }, link: { linkId: `${input.origin}-link`, - handle: { provider: 'codex', threadId: 'legacy-thread' }, + handle: codexProviderHandle('legacy-thread'), origin: input.origin, mintedAtFence: fence, observedAt: NOW @@ -139,7 +140,7 @@ describe('structured session acquisition options', () => { }, link: { linkId: `${origin}-link`, - handle: { provider: 'codex', threadId: 'legacy-thread' }, + handle: codexProviderHandle('legacy-thread'), origin, mintedAtFence: input.fence, observedAt: NOW @@ -460,7 +461,7 @@ describe('structured session acquisition options', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: 'legacy-thread' }, + handle: codexProviderHandle('legacy-thread'), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index e22dd1bd2890..076e2de7540d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { return { @@ -15,7 +16,7 @@ function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-session-1', leafUuid: null } + providerHandle: claudeProviderHandle('provider-session-1', null) } } @@ -24,7 +25,7 @@ function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-1', null), origin: 'created', mintedAtFence: fence, observedAt: 1 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 76a276abd230..4f532865452a 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 @@ -20,6 +20,7 @@ import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -101,7 +102,7 @@ function adapter(): StructuredAgentSessionAdapter { process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 3eccc4f94571..e86e30d8f5dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -5,6 +5,7 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionProviderHandleFromWire } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' import { @@ -49,10 +50,7 @@ async function readAdoptedTranscript( } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, - sessionId: - adopt.providerHandle.kind === 'claude' - ? adopt.providerHandle.sessionId - : adopt.providerHandle.threadId, + sessionId: agentSessionProviderHandleFromWire(adopt.providerHandle).nativeId, options: { filePath: adopt.transcriptPath } }) if (!prepared.ok) { @@ -100,10 +98,7 @@ async function applyAdoptedTranscript( const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, agent: params.agent, - sessionId: - adopt.providerHandle.kind === 'claude' - ? adopt.providerHandle.sessionId - : adopt.providerHandle.threadId, + sessionId: agentSessionProviderHandleFromWire(adopt.providerHandle).nativeId, fence: record.lease.runtimeFence, options: { filePath: adopt.transcriptPath } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 6111e9029621..5c718da0f3be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -119,7 +120,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 140a99201156..4b52c9446dec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -56,7 +57,7 @@ function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAda mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: 'created' as const, - handle: { provider: 'claude' as const, sessionId: CLAUDE_SESSION, leafUuid: null } + handle: claudeProviderHandle(CLAUDE_SESSION, null) } })), atRestCommands: catalog, 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 85f99056c3a7..415e261ab3ca 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 @@ -5,15 +5,17 @@ // the record store's compare-and-swap, which also owns the idempotency row, so // a retried attach replays instead of reserving a second owner. -import type { - AgentSessionJournalIdentity, - AgentSessionProviderHandle -} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionHandleProvider, AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import { + agentSessionProviderHandleBelongsTo, + agentSessionProviderHandleFromWire, + type AgentSessionWireProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity' import { codexProviderHandleLink } from '../../codex/codex-structured-owner-identity' import type { @@ -65,8 +67,9 @@ export type AgentSessionAttachParams = { * attach fingerprint: which tab shows the chat is not which conversation it attaches to. */ surfaceTabId?: string launchArgs?: string[] - /** Omitted only for create-by-intent; the adapter proves the durable handle. */ - providerHandle?: Exclude + /** Omitted only for create-by-intent; the adapter proves the durable handle. In the wire's + * form, because the attach fingerprint covers it as the client sent it. */ + providerHandle?: AgentSessionWireProviderHandle /** * Host-resolved only. Present when this create adopts an existing provider conversation rather * than starting one: it seeds the handle chain so the adapter resumes instead of creating, and @@ -76,7 +79,7 @@ export type AgentSessionAttachParams = { * without adopting — presence of a handle must never be what triggers a resume. */ adopt?: { - providerHandle: Exclude + providerHandle: AgentSessionWireProviderHandle /** Omitted only when the exact committed operation replays an already-imported journal. */ transcriptPath?: string } @@ -119,7 +122,13 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor export function admitAttachOrRefuse( params: AgentSessionAttachParams ): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { - if (params.providerHandle && params.providerHandle.kind !== params.provider) { + if ( + params.providerHandle && + !agentSessionProviderHandleBelongsTo( + agentSessionProviderHandleFromWire(params.providerHandle), + params.provider + ) + ) { return { ok: false, refusal: refuse( @@ -143,16 +152,9 @@ export function journalIdentityFor( params: AgentSessionAttachParams ): AgentSessionJournalIdentity { const head = agentSessionProviderHandleChainHead(record.providerHandleChain) - const providerHandle: AgentSessionProviderHandle = - head?.handle.provider === 'codex' - ? { kind: 'codex', threadId: head.handle.threadId } - : head?.handle.provider === 'claude' - ? { - kind: 'claude', - sessionId: head.handle.sessionId, - leafUuid: head.handle.leafUuid - } - : (params.providerHandle ?? { kind: 'opaque', agent: params.agent, value: 'pending' }) + const providerHandle = + head?.handle ?? + (params.providerHandle ? agentSessionProviderHandleFromWire(params.providerHandle) : null) return { sessionId: record.sessionId, workspaceId: params.location.workspaceId, @@ -265,7 +267,7 @@ async function reconcileAgainstProviderHistory(input: { const ADOPTED_HANDLE_FENCE = 1 function adoptedProviderHandleLink( - handle: Exclude, + handle: AgentSessionWireProviderHandle, observedAt: number ): AgentSessionProviderHandleLink { return handle.kind === 'claude' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 1bb3d36a5c43..868247eb37ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -55,7 +56,7 @@ function adapter(): StructuredAgentSessionAdapter { process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, link: { linkId: `claude-native-${fence}`, - handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' }, + handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb65..24657cad342d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -512,7 +512,7 @@ it('starts a new child for the next send after a Stop, on the same Claude conver }) // The wake resumes the same Claude conversation; the first test pins the leaf it resumes after. expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toMatchObject({ - sessionId: PROVIDER_SESSION_ID + nativeId: PROVIDER_SESSION_ID }) expect(resumed.closed).toBe(false) expect(await dispatch(next)).toMatchObject({ state: 'pending' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index aab219c779d6..7421fcef307c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -77,7 +78,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created' as const, mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index ec4c76e26ba2..94d08c054c51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -10,13 +10,14 @@ import { StructuredAgentSessionConversations } from './structured-agent-session- import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const journals = createTrackedJournalOpener() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e2376150891..edba26f241b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -47,6 +47,7 @@ import { } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -70,7 +71,7 @@ function crashedClaudeRecord(): AgentSessionRecord { providerHandleChain: [ { linkId, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION, null), origin: 'created', mintedAtFence: 13, observedAt: TOOL_STARTED_AT - 60_000 @@ -119,7 +120,7 @@ async function seedClaudeToolTurn(): Promise { workspaceId: LOCATION.workspaceId, hostId: LOCATION.executionHostId, agent: 'claude', - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } + providerHandle: claudeProviderHandle(PROVIDER_SESSION, null) }, database: openTestJournalHostDatabase(root), now: () => now @@ -406,7 +407,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () process, link: { linkId: `claude-${fence}-link`, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION, null), origin: 'resumed', mintedAtFence: fence, observedAt: RELAUNCHED_AT @@ -484,7 +485,7 @@ async function hostWithFailingFirstStart(failure: Error) { process, link: { linkId: `claude-${fence}-link`, - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + handle: claudeProviderHandle(PROVIDER_SESSION, null), origin: 'resumed', mintedAtFence: fence, observedAt: now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index b39790e6b2af..8c3c48c93ada 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -17,6 +17,7 @@ import { unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' @@ -34,7 +35,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, database: openTestJournalHostDatabase(root), now: () => 1_000 @@ -403,7 +404,7 @@ describe('dead structured-session generation settlement', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, database: openTestJournalHostDatabase(root), now: () => 1_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index b19c3db0c4f3..7b0e48bebadc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -25,6 +25,7 @@ import { openJournalOwingImport } from '../agent-session-journal/journal-owed-im import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const BODY: AgentJournalItemBody = { kind: 'message', @@ -37,7 +38,7 @@ const JOURNAL_IDENTITY = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } as const function identity(ordinal: number): AgentJournalItemIdentity { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index fca04e1028ac..31e7bb718591 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -24,6 +24,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -44,7 +45,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 38b85ca12d1f..d6590d6c61a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -22,6 +22,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -38,7 +39,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // A resume continues the chain the first start created. origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index c0e78b5a0c52..912e54dc2e19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -25,6 +25,7 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' // Everything before the journal is out of scope here; what matters is what the orchestration does // when the attach throws after acquisition. @@ -43,7 +44,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'repo-1::/workspace/app', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: SESSION } + providerHandle: codexProviderHandle(SESSION) } const SUBJECT = makeStructuredAgentStatusSubject( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index b82499c28301..a4a12bfce7ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -125,7 +126,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index b282fc4df323..49fb06e83bad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -15,12 +15,7 @@ export function structuredAgentSessionProviderSessionMetadata( record: AgentSessionRecord | null ): AgentProviderSessionMetadata | undefined { const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null - return head - ? { - key: 'session_id', - id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId - } - : undefined + return head ? { key: 'session_id', id: head.handle.nativeId } : undefined } export function readStructuredAgentSessionHistoryResult(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index ee0bb2bee929..c444f52c031e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -20,6 +20,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -50,7 +51,7 @@ describe('abandoning a structured agent-session host', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index c7667531110f..96fc601134b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() @@ -141,7 +142,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW 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 f66d2506804d..16251f3cd401 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 @@ -29,6 +29,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' let root: string let store: AgentSessionRecordStore @@ -111,7 +112,7 @@ describe('attach', () => { }, link: { linkId: 'stale-link', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence + 1, observedAt: NOW @@ -126,7 +127,7 @@ describe('attach', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index f91fc57c0558..81b7200c304e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -30,6 +30,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -108,7 +109,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created' as const, mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index c3eb1e324e9f..f428ab59a1bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -10,6 +10,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const roots: string[] = [] @@ -56,7 +57,7 @@ async function liveStore(): Promise { fence: reserved.record.lease.runtimeFence, link: { linkId: 'link-renewal', - handle: { provider: 'codex', threadId: 'thread-renewal' }, + handle: codexProviderHandle('thread-renewal'), origin: 'created', mintedAtFence: reserved.record.lease.runtimeFence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index c25640cc81cc..00b9f617891d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -125,7 +126,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 9ab31743cf4b..b8bda4c1b78c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -78,7 +79,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${input.fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created' as const, mintedAtFence: input.fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 203e31f4ea05..d46e2f7d38d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -18,6 +18,7 @@ import { import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' async function context(): Promise { return { @@ -29,7 +30,7 @@ async function context(): Promise { workspaceId: 'workspace', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: join(hostTestState().root, 'settlement') }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 8a9d93c35e73..624828702713 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -22,6 +22,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -66,7 +67,7 @@ function adapter(): StructuredAgentSessionAdapter { }, link: { linkId: `native-link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', mintedAtFence: fence, observedAt: NOW 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 c45ceac238c8..1c55baa57b0f 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 @@ -16,6 +16,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 { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -114,13 +115,14 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(false) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only `supportsCreate` and `acquire` on this fake adapter. const adapter = { supportsCreate, acquire: vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'link-1', - handle: { provider: 'codex' as const, threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created' as const, mintedAtFence: fence, observedAt: NOW @@ -263,6 +265,7 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const store = await openTestAgentSessionRecordStore(root) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only the members this fake adapter defines. const adapter = { acquire: vi .fn() @@ -276,7 +279,7 @@ describe('processless structured session reservation', () => { }, link: { linkId: 'link-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 3fed3c962919..2fa56d3fc6f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -14,13 +14,14 @@ import { performCancel, type AgentSessionTurnContext } from './structured-agent- import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const PROMPT_IDENTITY = { provider: 'codex' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6d42155fa386..c61d2bd117f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -41,6 +41,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -67,7 +68,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa acquisitionGeneration: generation(), link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), @@ -337,7 +338,7 @@ describe('settling an earlier child before the next one takes its message', () = workspaceId: HOST_TEST_LOCATION.workspaceId, hostId: HOST_TEST_LOCATION.executionHostId, agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, database: openTestJournalHostDatabase(root) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index de2951a6292a..57d249d2dbe4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -17,6 +17,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -34,7 +35,7 @@ function claudeAdapter(): StructuredAgentSessionAdapter { }, link: { linkId: `link-${fence}`, - handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null }, + handle: claudeProviderHandle(CLAUDE_SESSION, null), origin: 'created', mintedAtFence: fence, observedAt: HOST_TEST_NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f94d091a5f97..f1271f4e3395 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -92,7 +93,7 @@ export async function createQueuedMessageTestRig( ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 945f2cefbad2..361aae9d83f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -19,10 +19,12 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'codex_read_restore_fixture' const WORKSPACE_ID = 'repo-1::/tmp/workspace' +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal record the read path only reads; its fields match the record shape. const RECORD = { schemaVersion: 2, sessionId: SESSION_ID, @@ -36,7 +38,7 @@ const RECORD = { providerHandleChain: [ { linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 1, observedAt: 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e6..8acb047f2ccb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -31,6 +31,7 @@ import { StructuredAgentSessionStatusFeed } from './structured-agent-session-sta import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'recovered-turn-session' const THREAD = 'thread-1' @@ -60,7 +61,7 @@ async function sessionWithRunningTurn() { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, now: () => clock, stateDirectory: join(root, SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 228fa21fbc24..314e4012199f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -23,6 +23,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -110,7 +111,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW @@ -288,7 +289,7 @@ describe('recovery exits', () => { process: outgoing.process, link: { linkId: 'link-outgoing', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: 1, observedAt: NOW @@ -333,7 +334,7 @@ describe('recovery exits', () => { process: replacement.process, link: { linkId: 'link-replacement', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed', mintedAtFence: 3, observedAt: NOW + 2 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 8e3be9d1f3d7..bdeaa1527844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -15,6 +15,7 @@ import { resolveStructuredSessionRecovery, type StructuredSessionRecoveryResolutionDeps } from './structured-agent-session-recovery-resolution' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } @@ -81,7 +82,7 @@ async function liveOwner(store: AgentSessionRecordStore, pid = 4242) { fence, link: { linkId: 'link-recovery', - handle: { provider: 'codex', threadId: 'thread-recovery' }, + handle: codexProviderHandle('thread-recovery'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index d5899c794e3c..e2b5071d0fa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -72,7 +73,7 @@ async function createHarness(options: { attached?: boolean } = {}) { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index a16ef1101a2f..6fc7b6c2d52a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -51,7 +52,7 @@ beforeEach(async () => { acquisitionGeneration: 'generation-1', link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: fence > 1 ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index a2e843378a54..c14899af1128 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-event-sink' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const ROW: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'row' } @@ -45,7 +46,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } })) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0045684ccb37..bd21fa3ff339 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -34,6 +34,7 @@ import { import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' 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' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -145,7 +146,7 @@ export async function createRestTestRig( process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 93c33146743b..fd13cc3adb5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -13,6 +13,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' export const SESSION = 'session-working-1' export const THREAD = 'thread-1' @@ -53,7 +57,7 @@ export function record(overrides: { chain?: AgentSessionRecord['providerHandleCh providerHandleChain: overrides.chain ?? [ { linkId: 'link-1', - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: 1, observedAt: NOW @@ -124,7 +128,7 @@ export function claudeRecord( providerHandleChain: [ { linkId: 'link-1', - handle: { provider: 'claude', sessionId: providerSessionId, leafUuid }, + handle: claudeProviderHandle(providerSessionId, leafUuid), origin: 'created', mintedAtFence: 1, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 6cde8d87b842..38a47a06ebea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -40,7 +41,7 @@ function adapter(): StructuredAgentSessionAdapter { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 4fa2811472a4..20cd3b298d56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -40,6 +40,7 @@ import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -87,7 +88,7 @@ function recordFor(sessionId: string): AgentSessionRecord { providerHandleChain: [ { linkId: `codex-1-${sessionId}`, - handle: { provider: 'codex', threadId: `thread-${sessionId}` }, + handle: codexProviderHandle(`thread-${sessionId}`), origin: 'created', mintedAtFence: 1, observedAt: 1 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 49a116ec7f7a..a574010b34ee 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 @@ -15,6 +15,7 @@ import { } 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' function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessionRecord { const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) @@ -26,7 +27,7 @@ function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessio ? [ { ...record.providerHandleChain[0]!, - handle: { provider: 'codex', threadId: `thread-${sessionId}` } + handle: codexProviderHandle(`thread-${sessionId}`) } ] : record.providerHandleChain diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index c7240eccd833..8d5d5d54cbb8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -61,7 +62,7 @@ function adapter(): StructuredAgentSessionAdapter { mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: acquires === 1 ? 'created' : 'resumed', - handle: { provider: 'codex', threadId: THREAD } + handle: codexProviderHandle(THREAD) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index dc8eb1117005..5ee4ca72f691 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } let directory: string @@ -75,7 +76,7 @@ beforeEach(async () => { mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: acquires.length === 1 ? 'created' : 'resumed', - handle: { provider: 'codex', threadId: HOST_TEST_THREAD } + handle: codexProviderHandle(HOST_TEST_THREAD) } } }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index f95579d7750d..f8cc4ef26921 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -99,13 +99,14 @@ export async function rewindStructuredAgentSession( } const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId) const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId)) + // The head belongs to the record's provider: the record store refuses any other. const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle - if (!key || !head || key.provider !== head.provider) { + if (!key || !head || key.provider !== record.provider) { return rewindRefusal('invalid-target') } let boundary = selected - if (key.provider === 'codex' && head.provider === 'codex') { - if (key.threadId !== head.threadId) { + if (key.provider === 'codex') { + if (key.threadId !== head.nativeId) { return rewindRefusal('invalid-target') } boundary = snapshot.items.findIndex((item) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index e170461095b8..55acadfc562f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -11,6 +11,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() @@ -25,7 +26,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: root }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index cf746e29b740..63b392e35290 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -51,7 +52,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 8c5e609b971b..6cf15ca088c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -30,6 +30,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -135,7 +136,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // A re-acquire resumes the thread the first child minted, as a real adapter does. origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index f14393df8740..8298ac67daf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } @@ -98,7 +99,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW @@ -185,7 +186,7 @@ describe('settled attach retry', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -229,7 +230,7 @@ describe('settled attach retry', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 8a9deaad48c0..90e5e84cf861 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -19,6 +19,7 @@ import { turnVerdictFromDeathEvidence, UNVERIFIABLE_TURN_VERDICT } from './structured-agent-session-stale-turn-verdict' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' const RUNNING_IDENTITY = { @@ -368,7 +369,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => 1_000 @@ -422,7 +423,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now @@ -488,7 +489,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now @@ -559,7 +560,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now @@ -619,7 +620,7 @@ describe('stale session state on a cold acquire', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, stateDirectory: root, now: () => now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 792276702381..0afc0a05077d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -128,7 +129,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index cfb801c8fd6b..386040a05a60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -11,6 +11,7 @@ import { } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const EVIDENCE: AgentChildWorkEvidence[] = [{ type: 'session-ended', observedAt: 5 }] @@ -33,7 +34,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index a9c94cc4d2be..20a7fca25c14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -7,6 +7,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const journals = createTrackedJournalOpener() @@ -28,7 +29,7 @@ it('publishes the provider child startup phase, not which child it is', async () workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: root }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 30ddeea9678b..89e47785983a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -23,6 +23,7 @@ import { statusFeedChildView as childView } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const USER_IDENTITY = { @@ -61,7 +62,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index 09a3442985b1..f7893ea0a449 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -15,6 +15,7 @@ import { createTrackedJournalOpener } from '../agent-session-journal/journal-hos import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'clock-session' const THREAD = 'thread-1' @@ -55,7 +56,7 @@ async function openFeed() { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) }, now: () => (clock += 100), stateDirectory: join(root, SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 743a8e2287c4..f77086176fd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'status-session' const TURN_IDENTITY = { @@ -61,7 +62,7 @@ async function openJournal(sessionId = SESSION, now?: () => number) { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, now, stateDirectory: join(root, sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index aea5e93168a9..611b27f08f9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -14,6 +14,7 @@ import { type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'reenter-session' const journals = createTrackedJournalOpener() @@ -35,7 +36,7 @@ async function openJournal(): Promise { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index d47efcd4cad6..3dcc899b0819 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -22,6 +22,7 @@ import { StructuredAgentSessionStatusFeed } from './structured-agent-session-sta import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'recency-session' const CODEX_THREAD = 'thread-parent' @@ -54,7 +55,7 @@ async function openSession() { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'codex', threadId: CODEX_THREAD } + providerHandle: codexProviderHandle(CODEX_THREAD) }, now: tick, stateDirectory: join(root, SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 3881e8c429f2..239663f608c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -24,6 +24,10 @@ import { StructuredAgentSessionStatusFeed } from './structured-agent-session-sta import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'subscriber-session' @@ -47,7 +51,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'activity-churn-journal') }) @@ -67,7 +71,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'checkpoint-journal') }) @@ -106,7 +110,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'clock-journal') }) @@ -151,7 +155,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'catalog-journal') }) @@ -194,7 +198,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'hook-journal') }) @@ -223,7 +227,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'unread-journal') }) @@ -289,7 +293,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } + providerHandle: claudeProviderHandle('provider-1', null) }, stateDirectory: join(root, 'background-journal') }) @@ -338,7 +342,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'activity-journal') }) @@ -390,7 +394,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: journalDir }) @@ -441,7 +445,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: journalDir }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1f8fc80c863d..cce95e1ac0b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -41,6 +41,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -221,7 +222,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 6349e7b8ef60..5ab883380d54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -17,13 +17,14 @@ import { } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string | null = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index b2f30032aa90..0cbf5b8ce6c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -12,13 +12,14 @@ import { createDeferredStructuredAgentSessionEventSink } from './structured-agen import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } let root: string | null = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 08532f63c1fd..242f7c228778 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -51,6 +51,10 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -159,7 +163,7 @@ beforeEach(async () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed' as const, mintedAtFence: fence, observedAt: NOW @@ -196,8 +200,8 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< agent: provider, providerHandle: provider === 'codex' - ? { kind: 'codex', threadId: THREAD } - : { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + ? codexProviderHandle(THREAD) + : claudeProviderHandle('provider-session-alpha-1', null) }, database: openTestJournalHostDatabase(root) }) @@ -604,7 +608,7 @@ describe('already-wedged profiles become usable on load', () => { // The conversation survived: the codex thread was resumed, not recreated. expect(store.getRecord(SESSION)?.providerHandleChain[0]).toMatchObject({ linkId: 'codex-13-link', - handle: { threadId: THREAD } + handle: { nativeId: THREAD } }) // Why NOT acquired here: startup spawning a provider child for every recovered record is the // accumulation this stack removed. Unlatching is the migration's job; spawning is a hold's. @@ -679,7 +683,7 @@ describe('already-wedged profiles become usable on load', () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed' as const, mintedAtFence: fence, observedAt: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 24ae62a872bb..42cc18f923c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -22,6 +22,7 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from './agent-session-history-page' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const SESSION = 'wire-admission-session' const LARGE_TEXT = 'x'.repeat(250 * 1024) @@ -38,7 +39,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: root }) @@ -190,7 +191,7 @@ async function reopenWithOversizedRemoval(afterSequence: number): Promise { observedAt: HOST_TEST_NOW, // A start with no thread to resume creates one, whatever its fence. origin: - input.fence > 1 && input.identity.providerHandle.kind === 'codex' + input.fence > 1 && input.identity.providerHandle ? ('resumed' as const) : ('created' as const), - handle: { - provider: 'codex' as const, - threadId: - input.identity.providerHandle.kind === 'codex' - ? input.identity.providerHandle.threadId - : `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` - } + handle: codexProviderHandle( + input.identity.providerHandle?.nativeId ?? + `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + ) } } }), @@ -504,11 +502,7 @@ describe("the replacement's first send", () => { .mocked(adapter.acquire) .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) // No provider conversation to resume: the source's thread is not carried over. - expect(start?.identity.providerHandle).toEqual({ - kind: 'opaque', - agent: 'codex', - value: 'pending' - }) + expect(start?.identity.providerHandle).toBeNull() expect(start?.fence).toBe(2) expect(store.getRecord(replacement)?.lease).toMatchObject({ claimStatus: 'live' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 22766f28a416..41031cb3b3eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -33,6 +33,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' let state: ReturnType let compact: Mock> @@ -589,7 +590,7 @@ it('writes one exit row when the child dies mid-command, and the loop writes not acquisitionGeneration: `generation-${fence}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // The next child resumes the thread, as a real one does. origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, @@ -639,7 +640,7 @@ it('delivers the next message after a command whose child died and whose settlem acquisitionGeneration: `generation-${fence}`, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: 1 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 ad2119024339..d34bb5ac87fe 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 @@ -16,8 +16,7 @@ export function listStructuredProviderSessionOwnership( sessionId: record.sessionId, workspaceId: record.location.workspaceId, provider: record.provider, - providerSessionId: - link.handle.provider === 'codex' ? link.handle.threadId : link.handle.sessionId, + providerSessionId: link.handle.nativeId, lease: record.lease })) ) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 581bb29c3649..cdfb26842eb6 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -10,6 +10,10 @@ import { structuredAdoptionConflictError, type StructuredAgentSessionAdoptionOwnership } from './structured-agent-session-history-adoption' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const OPERATION = '1800000000000-00000000000000000000000000000001' @@ -30,7 +34,7 @@ function committedReplay(overrides: { callerKey?: string; operationId?: string } origin: 'adopted', mintedAtFence: 1, observedAt: 1_800_000_000_000, - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') } ], accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex-original' } @@ -128,11 +132,7 @@ describe('findCommittedStructuredAgentSessionAdoptionReplay', () => { record.providerHandleChain[0] = { ...record.providerHandleChain[0]!, origin: 'adopted', - handle: { - provider: 'claude', - sessionId: 'provider-session-alpha-1', - leafUuid: 'leaf-1' - } + handle: claudeProviderHandle('provider-session-alpha-1', 'leaf-1') } expect( 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 e64e8f799d8f..662b93dcb2c0 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -7,7 +7,10 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' +import { + agentSessionWireProviderHandle, + type AgentSessionWireProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' @@ -26,7 +29,7 @@ export type StructuredAgentSessionAdoption = { export type CommittedStructuredAgentSessionAdoptionReplay = { record: AgentSessionRecord - providerHandle: Exclude + providerHandle: AgentSessionWireProviderHandle } /** Exact committed-operation identity; attach still validates its fingerprint. */ @@ -58,22 +61,12 @@ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { ) { return null } - const providerSessionId = - adopted.handle.provider === 'codex' ? adopted.handle.threadId : adopted.handle.sessionId - if (providerSessionId !== input.providerSessionId) { + // The replay re-sends the adoption as its create sent it, so it needs the wire's form. + const providerHandle = agentSessionWireProviderHandle(adopted.handle) + if (adopted.handle.nativeId !== input.providerSessionId || !providerHandle) { return null } - return { - record, - providerHandle: - adopted.handle.provider === 'codex' - ? { kind: 'codex', threadId: adopted.handle.threadId } - : { - kind: 'claude', - sessionId: adopted.handle.sessionId, - leafUuid: adopted.handle.leafUuid - } - } + return { record, providerHandle } } /** diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 9904ca7994f4..73d01b5b88e8 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -13,6 +13,7 @@ import { openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'session-alpha-1' /** The fixture lease's last renewal. */ @@ -163,7 +164,7 @@ describe('a failed acquisition', () => { fence, link: { linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-1', null), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -203,7 +204,7 @@ describe('a failed acquisition', () => { fence, link: { linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-1', null), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index ec636a96eed5..a20143780ec1 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -8,6 +8,7 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const MATCHED = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const @@ -54,7 +55,7 @@ async function establishOwner( fence, link: { linkId: `link-${suffix}`, - handle: { provider: 'codex', threadId: `thread-${suffix}` }, + handle: codexProviderHandle(`thread-${suffix}`), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 0b5b6e5f2efd..1737ad45d3fd 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -16,6 +16,7 @@ import { appendAgentSessionProviderHandleLink, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleBelongsTo } from '../../shared/agent-session-provider-handle-encoding' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, @@ -145,7 +146,7 @@ export function proveAgentSessionOwner(args: { reason: 'spawnIdentityMismatch' }) } - if (args.link.handle.provider !== record.provider) { + if (!agentSessionProviderHandleBelongsTo(args.link.handle, record.provider)) { throw new Error('agent_session_provider_handle_provider_mismatch') } if (args.link.mintedAtFence !== args.fence) { 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 bf2156d0710b..3a9e47fe6c29 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -11,6 +11,10 @@ import { type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import { + encodePersistedAgentSessionProviderHandle, + isAgentSessionProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -66,6 +70,24 @@ const OPERATION: AgentSessionOperationRow = { outcome: { status: 'succeeded', sessionId: ALPHA } } +/** The records file only ever held handles in their stored form. */ +function storedForm(value: Record): Record { + const chain = value.providerHandleChain + return Array.isArray(chain) + ? { + ...value, + providerHandleChain: chain.map((link: unknown) => + typeof link === 'object' && + link !== null && + 'handle' in link && + isAgentSessionProviderHandle(link.handle) + ? { ...link, handle: encodePersistedAgentSessionProviderHandle(link.handle) } + : link + ) + } + : value +} + function legacyFile( records: readonly (Record & { sessionId: string })[], extra: Record = {} @@ -73,7 +95,7 @@ function legacyFile( return { schemaVersion: 2, hostId: 'local', - records: Object.fromEntries(records.map((value) => [value.sessionId, value])), + records: Object.fromEntries(records.map((value) => [value.sessionId, storedForm(value)])), operations: {}, retiredClaimKeys: [], unusableRecords: {}, @@ -246,7 +268,7 @@ describe('a record the file set aside', () => { const stored = database.db .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') .get(ALPHA) - expect(JSON.parse(String(stored?.record_json))).toEqual(unsupported) + expect(JSON.parse(String(stored?.record_json))).toEqual(storedForm(unsupported)) }) // #23589: both copies exist only when an older build recovered the readable one from its backup, @@ -385,7 +407,7 @@ describe('the tab index from before the table', () => { const owed = await install() owed.database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(BETA, JSON.stringify(record(BETA))) + .run(BETA, JSON.stringify(storedForm(record(BETA)))) await AgentSessionRecordStore.open({ journalDatabase: owed.database, hostId: 'local' diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index 0a0b4d49d5bb..f632f008e764 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -9,6 +9,7 @@ import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { NO_LEGACY_JOURNAL_RECORDS, type JournalLegacyRecordImport @@ -106,11 +107,15 @@ function importRows( for (const [sessionId, loaded] of records) { const setAside = state.unreadableRecords.get(sessionId) const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded - const json = JSON.stringify({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + const json = JSON.stringify( + encodeAgentSessionRecord({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + ) // A record the load rules refuse is kept as its bytes, which every load then sets aside. rows.push([ sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) ? json : JSON.stringify(loaded) + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) + ? json + : JSON.stringify(encodeAgentSessionRecord(loaded)) ]) } for (const [sessionId, { raw }] of state.unreadableRecords) { diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index 7a61ea7bdfab..fa3eea6ecf6d 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -5,15 +5,20 @@ import { } from '../../shared/agent-session-record.test-fixture' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { recordAgentSessionProviderHandle, - reviseAgentSessionClaudeResumePoint + reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' function resumedLink(fence: number): AgentSessionProviderHandleLink { return { linkId: 'link-2', - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('provider-session-alpha-1', 'leaf-2'), origin: 'resumed', mintedAtFence: fence, observedAt: 4_000 @@ -29,7 +34,7 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(record.lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) expect(next.lease.provenHandleLinkId).toBe('link-2') }) @@ -46,18 +51,17 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) }) }) -describe('reviseAgentSessionClaudeResumePoint', () => { +describe('reviseAgentSessionProviderResumePoint', () => { const revise = (record = agentSessionRecordFixture(), leafUuid = 'leaf-2') => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ record, fence: record.lease.runtimeFence, - providerSessionId: 'provider-session-alpha-1', - leafUuid, + handle: claudeProviderHandle('provider-session-alpha-1', leafUuid), now: 5_000 }) @@ -68,20 +72,19 @@ describe('reviseAgentSessionClaudeResumePoint', () => { expect(second.providerHandleChain.at(-1)).toMatchObject({ linkId: 'link-1', origin: 'created', - handle: { leafUuid: 'leaf-3' } + handle: { providerData: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') - expect(isPersistedAgentSessionRecord(second)).toBe(true) + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) }) it('refuses a stale owner, a released lease, and a head minted by another owner', () => { const record = agentSessionRecordFixture() expect(() => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ record, fence: record.lease.runtimeFence - 1, - providerSessionId: 'provider-session-alpha-1', - leafUuid: 'leaf-2', + handle: claudeProviderHandle('provider-session-alpha-1', 'leaf-2'), now: 5_000 }) ).toThrow('agent_session_stale_fence') @@ -96,11 +99,19 @@ describe('reviseAgentSessionClaudeResumePoint', () => { }) ).toThrow('agent_session_provider_handle_invalid') expect(() => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ + record, + fence: record.lease.runtimeFence, + handle: claudeProviderHandle('another-provider-session', 'leaf-2'), + now: 5_000 + }) + ).toThrow('agent_session_provider_handle_invalid') + // Same id, another provider's namespace: a different conversation, never this one's leaf. + expect(() => + reviseAgentSessionProviderResumePoint({ record, fence: record.lease.runtimeFence, - providerSessionId: 'another-provider-session', - leafUuid: 'leaf-2', + handle: codexProviderHandle('provider-session-alpha-1'), now: 5_000 }) ).toThrow('agent_session_provider_handle_invalid') diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index 57d2ecb172c0..a2c942441984 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -1,10 +1,14 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionProviderHandleChainHead, + agentSessionProviderHandleRoot, + agentSessionProviderHandlesEqual, appendAgentSessionProviderHandleLink, isAgentSessionProviderHandleChain, + type AgentSessionProviderHandle, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleBelongsTo } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecord } from '../../shared/agent-session-record' export function recordAgentSessionProviderHandle(args: { @@ -17,7 +21,10 @@ export function recordAgentSessionProviderHandle(args: { if (record.lease.runtimeFence !== args.fence) { throw new Error('agent_session_stale_fence') } - if (args.link.handle.provider !== record.provider || args.link.mintedAtFence !== args.fence) { + if ( + !agentSessionProviderHandleBelongsTo(args.link.handle, record.provider) || + args.link.mintedAtFence !== args.fence + ) { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { @@ -44,14 +51,15 @@ export function recordAgentSessionProviderHandle(args: { } /** - * Advance the live owner's Claude resume point in place. The head link this owner minted keeps - * its id and provenance; only its leaf moves, so a long conversation does not grow the chain. + * Move the live owner's resume point in place: the head link this owner minted keeps its id and + * provenance, and only the adapter-owned data its handle carries changes, so a long conversation + * does not grow the chain. The adapter decides what that data means; this only refuses a handle + * that names another conversation. */ -export function reviseAgentSessionClaudeResumePoint(args: { +export function reviseAgentSessionProviderResumePoint(args: { record: AgentSessionRecord fence: number - providerSessionId: string - leafUuid: string + handle: AgentSessionProviderHandle now: number }): AgentSessionRecord { const { record } = args @@ -63,18 +71,18 @@ export function reviseAgentSessionClaudeResumePoint(args: { } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) if ( - head?.handle.provider !== 'claude' || - head.handle.sessionId !== args.providerSessionId || + !head || + agentSessionProviderHandleRoot(head.handle) !== agentSessionProviderHandleRoot(args.handle) || head.mintedAtFence !== args.fence ) { throw new Error('agent_session_provider_handle_invalid') } - if (head.handle.leafUuid === args.leafUuid) { + if (agentSessionProviderHandlesEqual(head.handle, args.handle)) { return record } const providerHandleChain = [ ...record.providerHandleChain.slice(0, -1), - { ...head, handle: { ...head.handle, leafUuid: args.leafUuid }, observedAt: args.now } + { ...head, handle: args.handle, observedAt: args.now } ] // The revised chain must still read back as the same persisted chain. if (!isAgentSessionProviderHandleChain(providerHandleChain)) { diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts index 084cc93ac4d4..f9a3cfb3c91d 100644 --- a/src/main/runtime/agent-session-record-conversation-name.test.ts +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -1,14 +1,28 @@ import { describe, expect, it } from 'vitest' -import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import { + isPersistedAgentSessionRecord, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { encodePersistedAgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' const NOW = 9_000 +/** Validates the record as its row stores it. */ +function storedRecordIsValid( + record: Omit & { conversationName?: unknown } +): boolean { + return isPersistedAgentSessionRecord({ + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) + }) +} + describe('agent session record conversationName validation', () => { it('accepts a record carrying a bounded name', () => { expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'Fix the lease probe' }) @@ -16,12 +30,12 @@ describe('agent session record conversationName validation', () => { }) it('accepts a record with no name at all', () => { - expect(isPersistedAgentSessionRecord(agentSessionRecordFixture())).toBe(true) + expect(storedRecordIsValid(agentSessionRecordFixture())).toBe(true) }) it('rejects a name past the stored maximum', () => { expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'a'.repeat(201) }) @@ -29,23 +43,23 @@ describe('agent session record conversationName validation', () => { }) it('rejects a name that is not a string', () => { - expect( - isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 42 }) - ).toBe(false) - expect( - isPersistedAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: '' }) - ).toBe(false) + expect(storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 42 })).toBe( + false + ) + expect(storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: '' })).toBe( + false + ) }) it('rejects persisted names that bypassed canonical normalization', () => { expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'Fix\u202Egnp.exe probe' }) ).toBe(false) expect( - isPersistedAgentSessionRecord({ + storedRecordIsValid({ ...agentSessionRecordFixture(), conversationName: 'Fix\nthe probe' }) @@ -63,7 +77,7 @@ describe('setAgentSessionRecordConversationName', () => { expect(next.conversationName).toBe('Fix the lease probe') expect(next.updatedAt).toBe(NOW) - expect(isPersistedAgentSessionRecord(next)).toBe(true) + expect(storedRecordIsValid(next)).toBe(true) }) it('normalizes on the way in so the record stays valid whatever the caller sent', () => { @@ -74,7 +88,7 @@ describe('setAgentSessionRecordConversationName', () => { ) expect(next.conversationName).toBe('Fix the probe') - expect(isPersistedAgentSessionRecord(next)).toBe(true) + expect(storedRecordIsValid(next)).toBe(true) }) it('bounds an over-long name rather than storing a record the validator would reject', () => { @@ -85,7 +99,7 @@ describe('setAgentSessionRecordConversationName', () => { ) expect(next.conversationName).toHaveLength(200) - expect(isPersistedAgentSessionRecord(next)).toBe(true) + expect(storedRecordIsValid(next)).toBe(true) }) it('clears the name via null, deleting the key rather than storing an empty string', () => { @@ -99,7 +113,7 @@ describe('setAgentSessionRecordConversationName', () => { expect(Object.hasOwn(cleared, 'conversationName')).toBe(false) expect(cleared.updatedAt).toBe(NOW + 1) - expect(isPersistedAgentSessionRecord(cleared)).toBe(true) + expect(storedRecordIsValid(cleared)).toBe(true) }) it('treats a name that normalizes to nothing as a clear', () => { diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 58385e292726..c092018ea276 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-options' @@ -96,7 +97,7 @@ it('persists resumed provider options atomically with owner proof', async () => fence, link: { linkId: 'codex-options-1', - handle: { provider: 'codex', threadId: 'thread-options' }, + handle: codexProviderHandle('thread-options'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index be6f70f06e7d..1b1fe6e1b3b2 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -9,7 +9,7 @@ import { isPersistedAgentSessionRecord, type AgentSessionLease } from '../../shared/agent-session-record' -import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import type Database from '../sqlite/sync-database' import type { SqliteRow } from '../sqlite/sqlite-statement' import { @@ -112,7 +112,7 @@ export function loadAgentSessionStoreRows( const parsed = parseJson(text(row, 'record_json')) const value = parsed.ok ? parsed.value : text(row, 'record_json') if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { - const { record } = normalizeLegacyHandoffRecord(value) + const { record } = decodePersistedAgentSessionRecord(value) state.records.set(sessionId, { ...record, lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 79e65388fbd0..6b5632a87abf 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -16,7 +16,7 @@ import { isPersistedAgentSessionRecord, type AgentSessionRecord } from '../../shared/agent-session-record' -import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -130,7 +130,7 @@ function parseState(raw: string, hostId: string): Pick ({ + ...link, + handle: isAgentSessionProviderHandle(link.handle) + ? encodePersistedAgentSessionProviderHandle(link.handle) + : link.handle + })) + } +} + /** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, - seed: { records: readonly PersistedAgentSessionRecord[] } + seed: { records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] } ): Promise { writePersisted(databaseFor(stateDirectory), { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: TEST_HOST_ID, - records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), + records: Object.fromEntries( + seed.records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) + ), operations: {}, retiredClaimKeys: [], unusableRecords: {} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 12aa70b330cd..078f9b6d0eca 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -21,6 +21,10 @@ import { import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 @@ -87,7 +91,7 @@ function handleLink( ): AgentSessionProviderHandleLink { return { linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, + handle: claudeProviderHandle('provider-session-1', 'leaf-1'), origin: 'created', mintedAtFence: 1, observedAt: NOW, @@ -218,7 +222,7 @@ describe('acquisition path', () => { sessionId: 'session-alpha', fence: 1, link: handleLink({ - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') }), now: NOW }) diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e5f54265dcb7..8e02a2660afc 100644 --- a/src/main/runtime/agent-session-released-reservation-replay.test.ts +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -15,6 +15,7 @@ import type { AgentSessionLease } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -137,7 +138,7 @@ describe('a create retried after recovery released its reservation', () => { fence: 1, link: { linkId: 'link-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 1, observedAt: NOW diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index cad534931e60..0729ebfc35b7 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -16,6 +16,10 @@ import { type AgentSessionReserveRequest } from './agent-session-reservation-admission' import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const LEASE_TTL_MS = 60_000 @@ -34,7 +38,7 @@ function adoptedLink( ): AgentSessionProviderHandleLink { return { linkId: 'claude-1-provider-session-alpha-1-empty', - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, + handle: claudeProviderHandle('provider-session-alpha-1', null), origin: 'adopted', mintedAtFence: 1, observedAt: NOW, @@ -118,7 +122,7 @@ describe('adopted conversation ownership', () => { storeState([holder]), reserveRequest({ adoptedHandleLink: adoptedLink({ - handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } + handle: claudeProviderHandle('provider-session-other', null) }) }), LEASE_TTL_MS @@ -170,7 +174,7 @@ describe('adopted conversation ownership', () => { providerHandleChain: [ { linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: 7, observedAt: NOW @@ -187,7 +191,7 @@ describe('adopted conversation ownership', () => { accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, adoptedHandleLink: adoptedLink({ linkId: 'codex-1-thread-1-adopted', - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') }) }), LEASE_TTL_MS diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index 2afdfc13484b..e39027d0042a 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -3,6 +3,7 @@ * only once its rows have committed, so no reader ever sees a change that might still roll back. */ +import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import type { AgentSessionStoreState, RetiredAgentSessionClaimKey @@ -42,7 +43,8 @@ export type AgentSessionStoreRowWrites = { function serializeChangedRows( published: ReadonlyMap, next: ReadonlyMap, - readable: (key: string, written: unknown) => boolean + readable: (key: string, written: unknown) => boolean, + stored: (value: V) => unknown = (value) => value ): KeyedRowWrites { const writes: KeyedRowWrites = { upsert: [], remove: [] } let added = 0 @@ -51,7 +53,7 @@ function serializeChangedRows( if (prior === value) { continue } - const json = JSON.stringify(value) + const json = JSON.stringify(stored(value)) if (json === undefined || !readable(key, JSON.parse(json))) { throw new Error('agent_session_store_write_invalid') } @@ -85,8 +87,11 @@ export function agentSessionStoreDraftRowWrites( published: AgentSessionStoreState, draft: AgentSessionStoreState ): AgentSessionStoreRowWrites | null { - const records = serializeChangedRows(published.records, draft.records, (sessionId, written) => - isReadableAgentSessionStoreRecord(sessionId, written) + const records = serializeChangedRows( + published.records, + draft.records, + (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written), + encodeAgentSessionRecord ) const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => isReadableAgentSessionStoreOperation(key, written) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e028..6c297178e140 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -16,6 +16,7 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 @@ -76,7 +77,7 @@ async function liveChat( fence, link: { linkId: `link-${counter}`, - handle: { provider: 'claude', sessionId: `provider-${counter}`, leafUuid: null }, + handle: claudeProviderHandle(`provider-${counter}`, null), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index 31e473fb5e66..a6f002170db4 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -7,6 +7,7 @@ import { codexProviderHandleLink } from '../codex/codex-structured-owner-identit import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const NOW = 1_800_000_000_000 const SESSION = 'session-codex' @@ -116,7 +117,7 @@ describe('a Codex thread started in place of one Codex never saved', () => { expect(proved.lease.claimStatus).toBe('live') expect(proved.providerHandleChain).toEqual([ expect.objectContaining({ - handle: { provider: 'codex', threadId: 'thread-new' }, + handle: codexProviderHandle('thread-new'), origin: 'created', supersedesKey: 'codex:"thread-unsaved"' }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index a12fc06c479e..92d431293d3d 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -28,6 +28,7 @@ import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION = 'claude-integration-1' const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) @@ -768,19 +769,20 @@ describe('a structured Claude session over agentSession.*', () => { ).resolves.toMatchObject({ turnId: 'provider-opened-assistant', cancelled: true }) expect(claude.live().calls.at(-1)).toMatchObject({ subtype: 'interrupt' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only view of the host's store; only `getRecord` is read. const host = getStructuredAgentSessionHost() as unknown as { deps: { store: { getRecord: (sessionId: string) => { - providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + providerHandleChain: { handle: { transport: string; providerData?: string } }[] } } } } // A completed turn advances the durable resume point in place while the owner is live. expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)?.handle).toMatchObject({ - provider: 'claude', - leafUuid: 'assistant-leaf' + transport: 'claude-sdk', + providerData: 'assistant-leaf' }) // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next // open resumes the conversation. @@ -806,11 +808,7 @@ describe('a structured Claude session over agentSession.*', () => { expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) expect(claude.live().launch.options).not.toHaveProperty('resumeSessionAt') const lastCompletedTurn = { - handle: { - provider: 'claude', - sessionId: PROVIDER_SESSION, - leafUuid: 'provider-opened-assistant' - }, + handle: claudeProviderHandle(PROVIDER_SESSION, 'provider-opened-assistant'), origin: 'resumed' } expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index a5dffbeb48a4..839f80f2b81d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -35,11 +35,7 @@ export function lookupOrcaAgentSession( } const owner = store .listRecords() - .find((candidate) => - candidate.providerHandleChain.some(({ handle }) => - handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id - ) - ) + .find((candidate) => candidate.providerHandleChain.some(({ handle }) => handle.nativeId === id)) return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' } } diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.test.ts b/src/main/runtime/orchestration/structured-worker-journal-page.test.ts index d00d411a0675..b6e580fc77e3 100644 --- a/src/main/runtime/orchestration/structured-worker-journal-page.test.ts +++ b/src/main/runtime/orchestration/structured-worker-journal-page.test.ts @@ -17,6 +17,7 @@ import { readStructuredJournalPage, STRUCTURED_JOURNAL_PAGE_LIMIT } from './structured-worker-journal-page' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const host = vi.hoisted(() => { const state: { journal: AgentSessionJournal | null } = { journal: null } @@ -71,7 +72,7 @@ beforeEach(async () => { workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'claude-1', leafUuid: null } + providerHandle: claudeProviderHandle('claude-1', null) }, stateDirectory: root, now: () => ++clock, 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 6d1ddf9f3594..8f7b60de517c 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 @@ -14,6 +14,7 @@ import { RpcDispatcher } from '../dispatcher' 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' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -42,7 +43,7 @@ function adapter(): StructuredAgentSessionAdapter { }, link: { linkId: `codex-${fence}-${THREAD}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'resumed', mintedAtFence: fence, observedAt: 1_800_000_000_000 diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 769bd32f17b3..224fc8c0f5ad 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -29,6 +29,7 @@ import { agentSessionFailureFact } from '../../../../shared/agent-session-failur import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' const CONNECTION = 'connection-1' const CLIENT = { @@ -69,7 +70,7 @@ beforeEach(async () => { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? ('resumed' as const) : ('created' as const), diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index c97672269eb8..834a49b8e562 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -39,6 +39,7 @@ import { commitStructuredAgentSessionCreate } from './structured-agent-session-c import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' 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' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -74,10 +75,9 @@ function providerAdapter(): StructuredAgentSessionAdapter { mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, origin: 'created' as const, - handle: { - provider: 'codex' as const, - threadId: `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` - } + handle: codexProviderHandle( + `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + ) } } }), diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts index 396b0b2bec77..e200260a7e28 100644 --- a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -14,6 +14,7 @@ import { structuredWorkerIdentities } from '../structured-worker-identity' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' export const SESSION_X = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') export const SESSION_Y = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') @@ -43,11 +44,7 @@ export function sessionRecord( location: { ...base.location, workspaceId: WORKSPACE_X, ...overrides.location }, providerHandleChain: base.providerHandleChain.map((link) => ({ ...link, - handle: { - provider: 'claude' as const, - sessionId: overrides.providerId ?? `provider-${sessionId}`, - leafUuid: null - } + handle: claudeProviderHandle(overrides.providerId ?? `provider-${sessionId}`, null) })) } } diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 25379632875b..ff82a61406e7 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -21,6 +21,7 @@ import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/stru import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -67,7 +68,7 @@ async function openJournal(): Promise { workspaceId: WORKSPACE_ID, hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 5afd2d2e7701..5f6020e8d0f8 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -12,6 +12,7 @@ import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ @@ -66,7 +67,7 @@ async function awaitingApproval() { workspaceId: WORKTREE_ID, hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index b4d0399d10da..9678c137a799 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -21,6 +21,7 @@ import { TURN, type StructuredCodexRpcHarness } from './structured-codex-session-rpc-test-harness' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() const WORKSPACE = 'workspace-1' @@ -118,7 +119,7 @@ describe('a structured codex session over agentSession.*', () => { workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } const reopened = await journals.open({ identity, diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 3d9e72f74046..01d51311d65e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -44,6 +44,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const journals = createTrackedJournalOpener() @@ -406,7 +407,7 @@ describe('a structured codex session over agentSession.*', () => { workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } const journal = await journals.open({ identity, @@ -827,7 +828,7 @@ describe('a structured codex session over agentSession.*', () => { workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } + providerHandle: codexProviderHandle(THREAD) } const reopened = await journals.open({ identity, diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index dd76d01e6c64..5c2256ad8827 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const IMPORTED = 'session-alpha-1' @@ -45,7 +46,7 @@ const legacyFile = (): string => JSON.stringify({ schemaVersion: 2, hostId: 'local', - records: { [IMPORTED]: agentSessionRecordFixture() }, + records: { [IMPORTED]: storedTestAgentSessionRecord(agentSessionRecordFixture()) }, operations: {}, retiredClaimKeys: [], unusableRecords: {} diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 2727b2389f2f..706b4de5bce5 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('structured session runtime provider-exit wiring', () => { let root: string | null = null @@ -231,10 +232,9 @@ describe('structured session runtime provider-exit wiring', () => { await restarted.restoreReadableSessions() const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) - expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual({ - provider: 'codex', - threadId: 'thread-runtime-close' - }) + expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( + codexProviderHandle('thread-runtime-close') + ) }) it('waits for a start a send began before tearing down the runtime', async () => { diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index aee47ab15bae..16cb94a812ff 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' /** pid -> the NUL-separated environment block `/proc//environ` serves. */ const fakeProc = vi.hoisted(() => ({ environs: new Map() })) @@ -113,7 +114,7 @@ describe('a process that inherited a spawn token', () => { fence, link: { linkId: 'link-1', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), origin: 'created', mintedAtFence: fence, observedAt: NOW diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index ac6df4c4b2b3..70be04a9de72 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -27,7 +27,8 @@ import { } from './agent-session-record-store-file' import { readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild + seedTestAgentSessionStoreFromNewerBuild, + storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { OrcaRuntimeService } from './orca-runtime' @@ -37,6 +38,7 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -94,7 +96,7 @@ function chatRecord( provider: 'codex' as const, providerHandleChain: record.providerHandleChain.map((link) => ({ ...link, - handle: { provider: 'codex' as const, threadId: `thread-${sessionId}` } + handle: codexProviderHandle(`thread-${sessionId}`) })), accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') } } @@ -128,7 +130,9 @@ async function seedProfile( JSON.stringify({ schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: 'local', - records: Object.fromEntries(records.map((record) => [record.sessionId, record])), + records: Object.fromEntries( + records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) + ), operations: {}, retiredClaimKeys: [], unusableRecords: {}, @@ -182,7 +186,7 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(record.sessionId, JSON.stringify(record)) + .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) await AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 49c694b5adbd..337738e3e9d8 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -105,8 +105,7 @@ function connectionFor(sessionId: string): FakeConnection { const head = agentSessionProviderHandleChainHead( host.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] ) - const thread = - threadBySession.get(sessionId) ?? (head?.handle.provider === 'codex' && head.handle.threadId) + const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId const connection = codex.connections.findLast((candidate) => candidate.threadId === thread) if (!connection) { throw new Error(`no app-server for ${sessionId}`) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503d..c72d23ecf73e 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,5 +1,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' @@ -12,7 +13,7 @@ import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent- import type { ClaudeStructuredSessionEvent } from '../claude/claude-structured-session-state' import { recordAgentSessionProviderHandle, - reviseAgentSessionClaudeResumePoint + reviseAgentSessionProviderResumePoint } from './agent-session-provider-handle-transition' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -113,11 +114,10 @@ export function createStructuredClaudeRuntimeAdapter( }, persistResumePoint: async ({ sessionId, providerSessionId, leafUuid, fence }) => { await store.transitionHandoff(sessionId, (record: AgentSessionRecord) => - reviseAgentSessionClaudeResumePoint({ + reviseAgentSessionProviderResumePoint({ record, fence, - providerSessionId, - leafUuid, + handle: claudeProviderHandle(providerSessionId, leafUuid), now: Date.now() }) ) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..f6adbae1f1d2 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -15,6 +15,7 @@ import type { AgentSessionQuestionAnswer } from './agent-session-question-answer import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { AgentSessionContextUsage } from './agent-session-context-usage' +import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' export { type AgentType } @@ -39,10 +40,11 @@ export type AgentJournalCursor = { sequence: number } -/** The durable provider session a journal is bound to. - * Codex is one thread id; Claude needs the leaf because concurrent resumes of - * one session id branch the same transcript. */ -export type AgentSessionProviderHandle = +/** A provider handle as journal rows record it, and (without `opaque`) as + * `agentSession.attach` carries it. Persisted and on the wire: never reshape an + * arm. Derived from the in-memory handle by `agentSessionJournalProviderHandle`; + * `opaque` names any other transport, or `pending` before a handle is proved. */ +export type AgentSessionJournalProviderHandle = | { kind: 'codex'; threadId: string } | { kind: 'claude'; sessionId: string; leafUuid: string | null } | { kind: 'opaque'; agent: AgentType; value: string } @@ -58,7 +60,8 @@ export type AgentSessionJournalIdentity = { /** Execution host that owns the process, so a client restart adjudicates nothing. */ hostId: string agent: AgentType - providerHandle: AgentSessionProviderHandle + /** The record's proved handle; null before the provider has proved one. */ + providerHandle: AgentSessionProviderHandle | null } // ─── Item identity ────────────────────────────────────────────────────────── diff --git a/src/shared/agent-session-legacy-handoff-lease.test.ts b/src/shared/agent-session-legacy-handoff-lease.test.ts index 49a10558b818..5bd90fbc71ea 100644 --- a/src/shared/agent-session-legacy-handoff-lease.test.ts +++ b/src/shared/agent-session-legacy-handoff-lease.test.ts @@ -2,15 +2,11 @@ import { describe, expect, it } from 'vitest' import { leaseCarriesLegacyHandoffValues, normalizeLegacyHandoffLease, - normalizeLegacyHandoffRecord, terminalOwnerRefusalMessage, type PersistedAgentSessionLease } from './agent-session-legacy-handoff-lease' import type { AgentSessionClaimStatus, AgentSessionHandoffStage } from './agent-session-record' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from './agent-session-record.test-fixture' +import { agentSessionLeaseFixture } from './agent-session-record.test-fixture' const CLAIMS: AgentSessionClaimStatus[] = ['reserved', 'live', 'conflicted', 'released'] const STAGES: (AgentSessionHandoffStage | null)[] = [null, 'new-owner-proving', 'recovering'] @@ -70,16 +66,6 @@ describe('normalizing a lease the removed terminal handoff wrote', () => { claimStatus: 'conflicted' }) }) - - it('reports whether a record needed normalizing', () => { - const record = agentSessionRecordFixture() - expect(normalizeLegacyHandoffRecord(record)).toEqual({ record, normalized: false }) - const legacy = { ...record, lease: persisted({ runtimeKind: 'tui' }) } - expect(normalizeLegacyHandoffRecord(legacy)).toEqual({ - record: { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } }, - normalized: true - }) - }) }) describe('the refusal for a chat a terminal agent holds', () => { diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts index ea4ef2909e8c..ce06ddce0eb7 100644 --- a/src/shared/agent-session-legacy-handoff-lease.ts +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -13,6 +13,7 @@ import type { AgentSessionOwnerRuntimeKind, AgentSessionRecord } from './agent-session-record' +import type { PersistedAgentSessionProviderHandleLink } from './agent-session-provider-handle' type LegacyHandoffRuntimeKind = 'tui' type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' | 'manual-recovery' @@ -28,8 +29,13 @@ export type PersistedAgentSessionLease = Omit & { +/** A record as a row stores it. Decode through `decodePersistedAgentSessionRecord`. */ +export type PersistedAgentSessionRecord = Omit< + AgentSessionRecord, + 'lease' | 'providerHandleChain' +> & { lease: PersistedAgentSessionLease + providerHandleChain: PersistedAgentSessionProviderHandleLink[] } export function isPersistedAgentSessionRuntimeKind( @@ -85,14 +91,3 @@ export function terminalOwnerRefusalMessage(lease: AgentSessionLease): string { const process = owner?.processStartTimeMs != null ? ` (process ${owner.pid})` : '' return `This chat is still open in a terminal agent${process}. Quit that agent to continue the chat here.` } - -/** The in-memory record, plus whether decode changed anything the store must write back. */ -export function normalizeLegacyHandoffRecord(record: PersistedAgentSessionRecord): { - record: AgentSessionRecord - normalized: boolean -} { - return { - record: { ...record, lease: normalizeLegacyHandoffLease(record.lease) }, - normalized: leaseCarriesLegacyHandoffValues(record.lease) - } -} diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts new file mode 100644 index 000000000000..e15cc5339d86 --- /dev/null +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -0,0 +1,285 @@ +/** + * How a provider handle is stored and sent, and the only module that knows the two typed shapes + * the first structured transports were stored in. + * + * Claude and Codex handles are persisted and sent exactly as before this module existed, so an + * older build sharing the database or the wire still reads them. Every other transport is stored + * in the neutral shape, which an older build refuses as unreadable rather than mistaking for one + * of its own. Decoding is strict: each handle has exactly one stored form, and the key and root + * strings derived from it are persisted elsewhere, so none of them may ever change. + */ + +import type { + AgentSessionHandleProvider, + AgentSessionProviderHandle, + AgentSessionProviderHandleNamespace +} from './agent-session-provider-handle' +import type { AgentSessionJournalProviderHandle, AgentType } from './agent-session-journal-types' + +export const CLAUDE_STRUCTURED_HANDLE_NAMESPACE = { + transport: 'claude-sdk', + agent: 'claude' +} as const satisfies AgentSessionProviderHandleNamespace + +export const CODEX_STRUCTURED_HANDLE_NAMESPACE = { + transport: 'codex-app-server', + 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' + ? CLAUDE_STRUCTURED_HANDLE_NAMESPACE + : CODEX_STRUCTURED_HANDLE_NAMESPACE +} + +export function isAgentSessionProviderHandleInNamespace( + handle: AgentSessionProviderHandleNamespace, + namespace: AgentSessionProviderHandleNamespace +): boolean { + 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. */ +export function agentSessionProviderHandleBelongsTo( + handle: AgentSessionProviderHandle, + provider: AgentSessionHandleProvider +): boolean { + return isAgentSessionProviderHandleInNamespace( + handle, + agentSessionProviderHandleNamespace(provider) + ) +} + +/** Claude's provider data is its resume leaf: the transcript entry a resume continues from. */ +export function claudeProviderHandle( + sessionId: string, + leafUuid: string | null +): AgentSessionProviderHandle { + return { + ...CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + nativeId: sessionId, + ...(leafUuid === null ? {} : { providerData: leafUuid }) + } +} + +export function claudeProviderHandleLeafUuid(handle: AgentSessionProviderHandle): string | null { + return handle.providerData ?? null +} + +export function codexProviderHandle(threadId: string): AgentSessionProviderHandle { + return { ...CODEX_STRUCTURED_HANDLE_NAMESPACE, nativeId: threadId } +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +const MAX_HANDLE_FIELD_LENGTH = 512 +/** A generic handle's key carries escaped ids, so it may outgrow a legacy key's bound. */ +const MAX_NEUTRAL_HANDLE_KEY_LENGTH = 8192 +const MAX_PROVIDER_DATA_LENGTH = 4096 +const NAMESPACE_PART_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/ + +export function isAgentSessionProviderHandleField(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_HANDLE_FIELD_LENGTH && + value === value.trim() + ) +} + +function isNamespacePart(value: unknown): value is string { + return typeof value === 'string' && NAMESPACE_PART_PATTERN.test(value) +} + +function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean { + return ( + isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE) || + isAgentSessionProviderHandleInNamespace(handle, CODEX_STRUCTURED_HANDLE_NAMESPACE) + ) +} + +/** + * An in-memory handle. A Claude or Codex handle must also fit its typed stored shape: Claude's + * provider data is a leaf id, and Codex has none. + */ +export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { + if (typeof value !== 'object' || value === null || Object.hasOwn(value, 'provider')) { + return false + } + const transport = 'transport' in value ? value.transport : undefined + const agent = 'agent' in value ? value.agent : undefined + const nativeId = 'nativeId' in value ? value.nativeId : undefined + const providerData = 'providerData' in value ? value.providerData : undefined + if ( + !isNamespacePart(transport) || + !isNamespacePart(agent) || + !isAgentSessionProviderHandleField(nativeId) + ) { + return false + } + const namespace = { transport, agent } + if (isAgentSessionProviderHandleInNamespace(namespace, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { + return providerData === undefined + } + if (isAgentSessionProviderHandleInNamespace(namespace, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { + return providerData === undefined || isAgentSessionProviderHandleField(providerData) + } + return ( + providerData === undefined || + (typeof providerData === 'string' && + providerData.length > 0 && + providerData.length <= MAX_PROVIDER_DATA_LENGTH) + ) +} + +/** Bound for a persisted key that names a handle in this namespace (`forkedFromKey`, `supersedesKey`). */ +export function isAgentSessionProviderHandleKeyFor( + handle: AgentSessionProviderHandle, + value: unknown +): value is string { + return isLegacyNamespace(handle) + ? isAgentSessionProviderHandleField(value) + : typeof value === 'string' && value.length > 0 && value.length <= MAX_NEUTRAL_HANDLE_KEY_LENGTH +} + +// ─── Stored form ──────────────────────────────────────────────────────────── + +type StoredClaudeProviderHandle = { provider: 'claude'; sessionId: string; leafUuid: string | null } +type StoredCodexProviderHandle = { provider: 'codex'; threadId: string } + +/** A handle as a record row stores it. */ +export type PersistedAgentSessionProviderHandle = + | StoredClaudeProviderHandle + | StoredCodexProviderHandle + | AgentSessionProviderHandle + +export function encodePersistedAgentSessionProviderHandle( + handle: AgentSessionProviderHandle +): PersistedAgentSessionProviderHandle { + if (isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { + return { provider: 'claude', sessionId: handle.nativeId, leafUuid: handle.providerData ?? null } + } + if (isAgentSessionProviderHandleInNamespace(handle, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { + return { provider: 'codex', threadId: handle.nativeId } + } + return { + transport: handle.transport, + agent: handle.agent, + nativeId: handle.nativeId, + ...(handle.providerData === undefined ? {} : { providerData: handle.providerData }) + } +} + +export function decodePersistedAgentSessionProviderHandle( + value: unknown +): AgentSessionProviderHandle | null { + if (typeof value !== 'object' || value === null) { + return null + } + const provider = 'provider' in value ? value.provider : undefined + if (provider === 'claude') { + const sessionId = 'sessionId' in value ? value.sessionId : undefined + // Why: a null leaf and a missing one were never the same stored value; keep refusing the second. + const leafUuid = 'leafUuid' in value ? value.leafUuid : undefined + return isAgentSessionProviderHandleField(sessionId) && + (leafUuid === null || isAgentSessionProviderHandleField(leafUuid)) + ? claudeProviderHandle(sessionId, leafUuid) + : null + } + if (provider === 'codex') { + const threadId = 'threadId' in value ? value.threadId : undefined + return isAgentSessionProviderHandleField(threadId) ? codexProviderHandle(threadId) : null + } + // A Claude or Codex handle has exactly one stored form, the typed one. + if (!isAgentSessionProviderHandle(value) || isLegacyNamespace(value)) { + return null + } + return { + transport: value.transport, + agent: value.agent, + nativeId: value.nativeId, + ...(value.providerData === undefined ? {} : { providerData: value.providerData }) + } +} + +// ─── Identity strings (persisted: never change them) ──────────────────────── + +/** Stable string identity for one handle. Two handles with the same key name the same writer target. */ +export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle): string { + const stored = encodePersistedAgentSessionProviderHandle(handle) + if ('provider' in stored) { + return stored.provider === 'claude' + ? `claude:${JSON.stringify([stored.sessionId, stored.leafUuid])}` + : `codex:${JSON.stringify(stored.threadId)}` + } + // Why: provider data is resume state the adapter owns, not identity; only Claude's leaf, a + // branch cursor, was ever part of a key, and its key above is fixed by records already written. + return agentSessionProviderHandleRoot(handle) +} + +/** + * Identity root: the part that a resume must preserve. A resume that changes the root is a fork, + * whatever the provider called it. + */ +export function agentSessionProviderHandleRoot(handle: AgentSessionProviderHandle): string { + const stored = encodePersistedAgentSessionProviderHandle(handle) + if ('provider' in stored) { + return stored.provider === 'claude' + ? `claude:${JSON.stringify(stored.sessionId)}` + : `codex:${JSON.stringify(stored.threadId)}` + } + // Namespace parts cannot hold `/` or `:`, so this never reads as a typed root. + return `${handle.transport}/${handle.agent}:${JSON.stringify(handle.nativeId)}` +} + +// ─── Wire and journal forms ───────────────────────────────────────────────── + +/** A handle as `agentSession.attach` carries it and an attach fingerprint covers it. */ +export type AgentSessionWireProviderHandle = Exclude< + AgentSessionJournalProviderHandle, + { kind: 'opaque' } +> + +export function agentSessionProviderHandleFromWire( + wire: AgentSessionWireProviderHandle +): AgentSessionProviderHandle { + return wire.kind === 'claude' + ? claudeProviderHandle(wire.sessionId, wire.leafUuid) + : codexProviderHandle(wire.threadId) +} + +/** Null for a transport the attach wire cannot carry. */ +export function agentSessionWireProviderHandle( + handle: AgentSessionProviderHandle +): AgentSessionWireProviderHandle | null { + const stored = encodePersistedAgentSessionProviderHandle(handle) + if (!('provider' in stored)) { + return null + } + return stored.provider === 'claude' + ? { kind: 'claude', sessionId: stored.sessionId, leafUuid: stored.leafUuid } + : { kind: 'codex', threadId: stored.threadId } +} + +/** What a journal row records before the provider has proved any handle. */ +export const AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE = 'pending' + +/** The handle a journal row records. Write-only: rows keep it for diagnosis, nothing reads it. */ +export function agentSessionJournalProviderHandle( + handle: AgentSessionProviderHandle | null, + agent: AgentType +): AgentSessionJournalProviderHandle { + if (!handle) { + return { kind: 'opaque', agent, value: AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } + } + return ( + agentSessionWireProviderHandle(handle) ?? { + kind: 'opaque', + agent: handle.agent, + value: handle.nativeId + } + ) +} diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 7474c751cbe0..463eff97ade7 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -13,12 +13,9 @@ import { type AgentSessionProviderHandle, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' +import { claudeProviderHandle, codexProviderHandle } from './agent-session-provider-handle-encoding' -const CLAUDE: AgentSessionProviderHandle = { - provider: 'claude', - sessionId: 'sess-1', - leafUuid: 'leaf-1' -} +const CLAUDE: AgentSessionProviderHandle = claudeProviderHandle('sess-1', 'leaf-1') function link(overrides: Partial = {}) { return { @@ -42,41 +39,35 @@ describe('handle identity', () => { it('keys a Claude handle by session id AND leaf, so two branches are two handles', () => { // Concurrent resumes branch one transcript silently; the session id alone cannot name a writer. const branchA = agentSessionProviderHandleKey(CLAUDE) - const branchB = agentSessionProviderHandleKey({ ...CLAUDE, leafUuid: 'leaf-2' }) + const branchB = agentSessionProviderHandleKey(claudeProviderHandle('sess-1', 'leaf-2')) expect(branchA).not.toEqual(branchB) expect(agentSessionProviderHandleRoot(CLAUDE)).toEqual( - agentSessionProviderHandleRoot({ ...CLAUDE, leafUuid: 'leaf-2' }) + agentSessionProviderHandleRoot(claudeProviderHandle('sess-1', 'leaf-2')) ) }) it('keys a Codex handle by thread id alone', () => { - const codex: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-1' } + const codex: AgentSessionProviderHandle = codexProviderHandle('thread-1') expect(agentSessionProviderHandleKey(codex)).toBe('codex:"thread-1"') expect(agentSessionProviderHandleRoot(codex)).toBe('codex:"thread-1"') - expect( - agentSessionProviderHandlesEqual(codex, { provider: 'codex', threadId: 'thread-2' }) - ).toBe(false) + expect(agentSessionProviderHandlesEqual(codex, codexProviderHandle('thread-2'))).toBe(false) }) it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => { - expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: null })).toBe(true) - expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: '' })).toBe(false) - expect(isAgentSessionProviderHandle({ provider: 'claude', sessionId: '' })).toBe(false) + expect(isAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toBe(true) + expect(isAgentSessionProviderHandle({ ...CLAUDE, providerData: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: ' sess-1 ' })).toBe(false) + // A stored typed shape is not an in-memory handle: it must be decoded first. + expect( + isAgentSessionProviderHandle({ provider: 'claude', sessionId: 'x', leafUuid: null }) + ).toBe(false) expect(isAgentSessionProviderHandle({ provider: 'gemini', sessionId: 'x' })).toBe(false) - expect(isAgentSessionProviderHandle({ ...CLAUDE, sessionId: ' sess-1 ' })).toBe(false) }) it('uses collision-free keys when Claude ids contain delimiters', () => { - const left: AgentSessionProviderHandle = { - provider: 'claude', - sessionId: 'a#b', - leafUuid: 'c' - } - const right: AgentSessionProviderHandle = { - provider: 'claude', - sessionId: 'a', - leafUuid: 'b#c' - } + const left = claudeProviderHandle('a#b', 'c') + const right = claudeProviderHandle('a', 'b#c') expect(agentSessionProviderHandleKey(left)).not.toBe(agentSessionProviderHandleKey(right)) expect(agentSessionProviderHandlesEqual(left, right)).toBe(false) }) @@ -101,7 +92,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + handle: claudeProviderHandle('sess-2', 'leaf-9'), mintedAtFence: 2 }) ) @@ -113,7 +104,7 @@ describe('chain append', () => { const forked = link({ linkId: 'link-2', origin: 'forked', - handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + handle: claudeProviderHandle('sess-2', 'leaf-9'), mintedAtFence: 2, forkedFromKey: agentSessionProviderHandleKey(CLAUDE) }) @@ -138,7 +129,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 4 }) ) @@ -152,7 +143,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), mintedAtFence: 2 }) ) @@ -182,7 +173,7 @@ describe('chain append', () => { [link()], link({ origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ) @@ -196,7 +187,7 @@ describe('chain append', () => { link({ linkId: `link-${index + 1}`, origin: 'resumed', - handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` }, + handle: claudeProviderHandle('sess-1', `leaf-${index + 1}`), mintedAtFence: index + 1 }) ) @@ -208,7 +199,7 @@ describe('chain append', () => { link({ linkId: 'link-overflow', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-overflow' }, + handle: claudeProviderHandle('sess-1', 'leaf-overflow'), mintedAtFence: 999 }) ) @@ -226,7 +217,7 @@ describe('chain append', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ) @@ -241,7 +232,7 @@ describe('chain lookup and validation', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ) @@ -282,7 +273,7 @@ describe('chain lookup and validation', () => { link(), link({ origin: 'resumed', - handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-1', 'leaf-2'), mintedAtFence: 2 }) ]) @@ -293,7 +284,7 @@ describe('chain lookup and validation', () => { link({ linkId: 'link-2', origin: 'resumed', - handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-2' }, + handle: claudeProviderHandle('sess-2', 'leaf-2'), mintedAtFence: 2 }) ]) @@ -308,7 +299,7 @@ describe('adopted chain heads', () => { link({ linkId: 'claude-1-sess-1-empty', origin: 'adopted', - handle: { ...CLAUDE, leafUuid: null }, + handle: claudeProviderHandle('sess-1', null), ...overrides }) @@ -333,7 +324,7 @@ describe('adopted chain heads', () => { link({ linkId: 'claude-1-sess-1-empty-retry', origin: 'resumed', - handle: { ...CLAUDE, leafUuid: null }, + handle: claudeProviderHandle('sess-1', null), mintedAtFence: 1 }) ) @@ -345,12 +336,12 @@ describe('adopted chain heads', () => { const codexAdopted = link({ linkId: 'codex-1-thread-1', origin: 'adopted', - handle: { provider: 'codex', threadId: 'thread-1' } + handle: codexProviderHandle('thread-1') }) const reproved = link({ linkId: 'codex-1-thread-1-retry', origin: 'resumed', - handle: { provider: 'codex', threadId: 'thread-1' }, + handle: codexProviderHandle('thread-1'), mintedAtFence: 1 }) @@ -380,7 +371,7 @@ describe('adopted chain heads', () => { link({ linkId: 'claude-1-sess-9-leaf-9', origin: 'resumed', - handle: { provider: 'claude', sessionId: 'sess-9', leafUuid: 'leaf-9' }, + handle: claudeProviderHandle('sess-9', 'leaf-9'), mintedAtFence: 1 }) ) @@ -393,12 +384,12 @@ describe('adopted chain heads', () => { }) describe('superseding a creation the provider never saved', () => { - const unsaved: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-unsaved' } + const unsaved: AgentSessionProviderHandle = codexProviderHandle('thread-unsaved') const created = link({ linkId: 'codex-1-thread-unsaved', handle: unsaved }) function replacement(overrides: Partial = {}) { return link({ linkId: 'codex-3-thread-new', - handle: { provider: 'codex', threadId: 'thread-new' }, + handle: codexProviderHandle('thread-new'), mintedAtFence: 3, supersedesKey: agentSessionProviderHandleKey(unsaved), ...overrides @@ -414,13 +405,13 @@ describe('superseding a creation the provider never saved', () => { chain, replacement({ linkId: 'codex-5-thread-newer', - handle: { provider: 'codex', threadId: 'thread-newer' }, + handle: codexProviderHandle('thread-newer'), mintedAtFence: 5, - supersedesKey: agentSessionProviderHandleKey({ provider: 'codex', threadId: 'thread-new' }) + supersedesKey: agentSessionProviderHandleKey(codexProviderHandle('thread-new')) }) ) expect(again).toHaveLength(1) - expect(again[0]?.handle).toEqual({ provider: 'codex', threadId: 'thread-newer' }) + expect(again[0]?.handle).toEqual(codexProviderHandle('thread-newer')) }) it('never supersedes a conversation a resume, fork or adoption proved', () => { @@ -467,3 +458,64 @@ describe('superseding a creation the provider never saved', () => { expect(isAgentSessionProviderHandleChain([created, replacement()])).toBe(false) }) }) + +describe('a transport shared code has never heard of', () => { + const acp = (nativeId: string, providerData?: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId, + ...(providerData === undefined ? {} : { providerData }) + }) + + it('chains resumes and forks by its native id alone', () => { + const created = link({ handle: acp('s-1', '{"cwd":"/a"}') }) + const resumed = link({ + linkId: 'link-2', + origin: 'resumed', + handle: acp('s-1', '{"cwd":"/b"}'), + mintedAtFence: 2 + }) + const chain = appendAgentSessionProviderHandleLink([created], resumed) + expect(chain).toHaveLength(2) + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + expect(() => + appendAgentSessionProviderHandleLink(chain, { + ...resumed, + linkId: 'link-3', + handle: acp('s-2') + }) + ).toThrow('agent_session_provider_handle_forked') + const forked = appendAgentSessionProviderHandleLink(chain, { + ...resumed, + linkId: 'link-3', + origin: 'forked', + handle: acp('s-2'), + forkedFromKey: agentSessionProviderHandleKey(resumed.handle) + }) + expect(forked.at(-1)?.handle.nativeId).toBe('s-2') + }) + + it('records a same-fence resume that only moved provider data instead of eliding it', () => { + const created = link({ handle: acp('s-1', 'a') }) + const moved = link({ linkId: 'link-2', origin: 'resumed', handle: acp('s-1', 'b') }) + expect(appendAgentSessionProviderHandleLink([created], moved)).toHaveLength(2) + expect( + appendAgentSessionProviderHandleLink([created], { ...moved, handle: acp('s-1', 'a') }) + ).toHaveLength(1) + }) + + it('never joins a chain of another transport or agent, even with the same id', () => { + const chain = [link({ handle: codexProviderHandle('shared-id') })] + for (const handle of [ + acp('shared-id'), + { transport: 'codex-app-server', agent: 'grok', nativeId: 'shared-id' } + ]) { + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-2', origin: 'resumed', handle, mintedAtFence: 2 }) + ) + ).toThrow() + } + }) +}) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index ea789dcdfe40..c16c87e1490a 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -1,14 +1,36 @@ /** * Durable provider handle chain for an agent session. * - * Handles are keyed per provider because the two structured lanes disagree about what - * identifies a conversation. Claude's session id is the identity root and its leaf uuid is a - * branch cursor; Codex's thread id is the whole key. Resumes extend the chain, forks start a new - * identity root, and the chain records which is which so a fork is never presented as a resume. - * A creation the provider never saved can be superseded by a new creation, which takes its place - * instead of standing beside it: the unsaved handle was never a conversation to continue. + * A handle names one provider conversation without saying what the provider means by it. Shared + * code reads only its transport, its agent, and the provider's own conversation id; anything else + * the provider needs to resume is `providerData`, which only that provider's adapter reads. How a + * handle is stored and sent lives in agent-session-provider-handle-encoding.ts. + * + * Resumes extend the chain, forks start a new identity root, and the chain records which is which + * so a fork is never presented as a resume. A creation the provider never saved can be superseded + * by a new creation, which takes its place instead of standing beside it: the unsaved handle was + * never a conversation to continue. */ +import type { AgentType } from './agent-status-types' +import { + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + decodePersistedAgentSessionProviderHandle, + encodePersistedAgentSessionProviderHandle, + isAgentSessionProviderHandle, + isAgentSessionProviderHandleInNamespace, + isAgentSessionProviderHandleKeyFor, + type PersistedAgentSessionProviderHandle +} from './agent-session-provider-handle-encoding' + +export { + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + isAgentSessionProviderHandle +} from './agent-session-provider-handle-encoding' + +/** The structured lanes this build drives. A record names one; a handle never does. */ export const AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS = ['claude', 'codex'] as const export type AgentSessionHandleProvider = (typeof AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS)[number] @@ -18,9 +40,24 @@ export function isAgentSessionHandleProvider(value: unknown): value is AgentSess return value === 'claude' || value === 'codex' } -export type AgentSessionProviderHandle = - | { provider: 'claude'; sessionId: string; leafUuid: string | null } - | { provider: 'codex'; threadId: string } +/** The protocol whose id space a handle's `nativeId` lives in. Open: a new transport adds no arm. */ +export type AgentSessionProviderTransport = 'claude-sdk' | 'codex-app-server' | (string & {}) + +export type AgentSessionProviderHandle = { + transport: AgentSessionProviderTransport + /** Orca agent whose binary resumes the conversation; one transport serves many agents. */ + agent: AgentType + /** The provider's own conversation id: Claude's session id, Codex's thread id. */ + nativeId: string + /** Resume state only the provider's adapter reads, such as Claude's branch leaf. */ + providerData?: string +} + +/** Which provider a handle belongs to. Records and leases compare this, never the handle's data. */ +export type AgentSessionProviderHandleNamespace = Pick< + AgentSessionProviderHandle, + 'transport' | 'agent' +> export type AgentSessionProviderHandleOrigin = 'created' | 'adopted' | 'resumed' | 'forked' @@ -43,54 +80,19 @@ export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandl /** Bounded so one session cannot grow an unbounded persisted record. */ export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 -const MAX_HANDLE_FIELD_LENGTH = 512 const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ -function isHandleField(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - value.length <= MAX_HANDLE_FIELD_LENGTH && - value === value.trim() - ) -} - -export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { - if (typeof value !== 'object' || value === null) { - return false - } - const handle = value as Partial & Record - if (handle.provider === 'claude') { - return ( - isHandleField(handle.sessionId) && - (handle.leafUuid === null || isHandleField(handle.leafUuid)) - ) - } - return handle.provider === 'codex' && isHandleField(handle.threadId) -} - -/** Stable string identity for one handle. Two handles with the same key name the same writer target. */ -export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle): string { - return handle.provider === 'claude' - ? `claude:${JSON.stringify([handle.sessionId, handle.leafUuid])}` - : `codex:${JSON.stringify(handle.threadId)}` -} - -/** - * Identity root: the part that a resume must preserve. A resume that changes the root is a fork, - * whatever the provider called it. - */ -export function agentSessionProviderHandleRoot(handle: AgentSessionProviderHandle): string { - return handle.provider === 'claude' - ? `claude:${JSON.stringify(handle.sessionId)}` - : `codex:${JSON.stringify(handle.threadId)}` -} - +/** Every field, provider data included: a resume that only moved the adapter's state is still news. */ export function agentSessionProviderHandlesEqual( left: AgentSessionProviderHandle, right: AgentSessionProviderHandle ): boolean { - return agentSessionProviderHandleKey(left) === agentSessionProviderHandleKey(right) + return ( + left.transport === right.transport && + left.agent === right.agent && + left.nativeId === right.nativeId && + left.providerData === right.providerData + ) } export function agentSessionProviderHandleChainHead( @@ -113,6 +115,10 @@ export function isAgentSessionProviderHandleLink( return false } const link = value as Partial + if (!isAgentSessionProviderHandle(link.handle)) { + return false + } + const handle = link.handle const originValid = link.origin === 'created' || link.origin === 'adopted' || @@ -121,16 +127,15 @@ export function isAgentSessionProviderHandleLink( return ( typeof link.linkId === 'string' && LINK_ID_PATTERN.test(link.linkId) && - isAgentSessionProviderHandle(link.handle) && originValid && Number.isSafeInteger(link.mintedAtFence) && (link.mintedAtFence as number) >= 0 && Number.isSafeInteger(link.observedAt) && (link.origin === 'forked' - ? isHandleField(link.forkedFromKey) + ? isAgentSessionProviderHandleKeyFor(handle, link.forkedFromKey) : link.forkedFromKey === undefined) && (link.supersedesKey === undefined || - (link.origin === 'created' && isHandleField(link.supersedesKey))) + (link.origin === 'created' && isAgentSessionProviderHandleKeyFor(handle, link.supersedesKey))) ) } @@ -177,7 +182,7 @@ export function appendAgentSessionProviderHandleLink( } return [link] } - if (link.handle.provider !== head.handle.provider) { + if (!isAgentSessionProviderHandleInNamespace(link.handle, head.handle)) { throw new Error('agent_session_provider_handle_provider_mismatch') } if (link.mintedAtFence < head.mintedAtFence) { @@ -245,3 +250,43 @@ function supersedeUnsavedCreation( // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. return [link] } + +// ─── Stored form ──────────────────────────────────────────────────────────── + +/** A link as a record row stores it: only the handle has a stored form of its own. */ +export type PersistedAgentSessionProviderHandleLink = Omit< + AgentSessionProviderHandleLink, + 'handle' +> & { + handle: PersistedAgentSessionProviderHandle +} + +export function encodePersistedAgentSessionProviderHandleChain( + chain: AgentSessionProviderHandleChain +): PersistedAgentSessionProviderHandleLink[] { + return chain.map((link) => ({ + ...link, + handle: encodePersistedAgentSessionProviderHandle(link.handle) + })) +} + +/** The in-memory chain a stored one names, or null when any link or the chain itself is invalid. */ +export function decodePersistedAgentSessionProviderHandleChain( + value: unknown +): AgentSessionProviderHandleLink[] | null { + if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return null + } + const decoded: unknown[] = [] + for (const link of value) { + const handle = + typeof link === 'object' && link !== null && 'handle' in link + ? decodePersistedAgentSessionProviderHandle(link.handle) + : null + if (!handle) { + return null + } + decoded.push({ ...link, handle }) + } + return isAgentSessionProviderHandleChain(decoded) ? decoded : null +} diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts new file mode 100644 index 000000000000..5070ae67d47e --- /dev/null +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' +import { isAgentSessionHandleProvider } from './agent-session-provider-handle' +import { + agentSessionJournalProviderHandle, + agentSessionProviderHandleFromWire, + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + agentSessionWireProviderHandle, + claudeProviderHandle, + codexProviderHandle, + decodePersistedAgentSessionProviderHandle, + encodePersistedAgentSessionProviderHandle +} from './agent-session-provider-handle-encoding' +import { isPersistedAgentSessionRecord } from './agent-session-record' +import { + decodePersistedAgentSessionRecord, + encodeAgentSessionRecord +} from './agent-session-record-stored-form' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from './agent-session-record.test-fixture' + +/** The rules of the handle guard builds before the neutral handle shipped ran on every stored one. */ +function olderBuildReadsHandle(value: unknown): boolean { + const isHandleField = (field: unknown): boolean => + typeof field === 'string' && field.length > 0 && field.length <= 512 && field === field.trim() + if (typeof value !== 'object' || value === null || !('provider' in value)) { + return false + } + if (value.provider === 'claude') { + return ( + 'sessionId' in value && + isHandleField(value.sessionId) && + 'leafUuid' in value && + (value.leafUuid === null || isHandleField(value.leafUuid)) + ) + } + return value.provider === 'codex' && 'threadId' in value && isHandleField(value.threadId) +} + +/** A Claude record row exactly as builds before this change wrote it: a fork with its seed key. */ +const STORED_CLAUDE_ROW = JSON.stringify({ + schemaVersion: 2, + sessionId: 'session-alpha-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + providerHandleChain: [ + { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }, + origin: 'created', + mintedAtFence: 6, + observedAt: 1000 + }, + { + linkId: 'link-2', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: null }, + origin: 'forked', + mintedAtFence: 7, + observedAt: 1500, + forkedFromKey: 'claude:["sess-1","leaf-1"]' + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, + lease: agentSessionLeaseFixture({ provenHandleLinkId: 'link-2' }), + createdAt: 1000, + updatedAt: 2000 +}) + +/** A Codex row with a creation that replaced one Codex never saved. */ +const STORED_CODEX_ROW = JSON.stringify({ + ...JSON.parse(STORED_CLAUDE_ROW), + provider: 'codex', + providerHandleChain: [ + { + linkId: 'codex-7-thread-new', + handle: { provider: 'codex', threadId: 'thread-new' }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1500, + supersedesKey: 'codex:"thread-unsaved"' + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + lease: agentSessionLeaseFixture({ provenHandleLinkId: 'codex-7-thread-new' }) +}) + +describe('a record row older builds wrote', () => { + it.each([ + ['Claude', STORED_CLAUDE_ROW], + ['Codex', STORED_CODEX_ROW] + ])('reads a %s row and writes it back byte for byte', (_name, row) => { + const stored: unknown = JSON.parse(row) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { + return + } + const { record, normalized } = decodePersistedAgentSessionRecord(stored) + expect(normalized).toBe(false) + expect(JSON.stringify(encodeAgentSessionRecord(record))).toBe(row) + }) + + it('hands shared code only the neutral handle', () => { + const stored: unknown = JSON.parse(STORED_CLAUDE_ROW) + if (!isPersistedAgentSessionRecord(stored)) { + throw new Error('fixture row must be readable') + } + const { record } = decodePersistedAgentSessionRecord(stored) + expect(record.providerHandleChain.map((link) => link.handle)).toEqual([ + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', providerData: 'leaf-1' }, + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-2' } + ]) + }) + + it('still reports a lease only the removed terminal handoff wrote', () => { + const record = agentSessionRecordFixture() + const stored = encodeAgentSessionRecord(record) + expect(decodePersistedAgentSessionRecord(stored)).toEqual({ record, normalized: false }) + const legacy = { ...stored, lease: { ...stored.lease, runtimeKind: 'tui' as const } } + expect(decodePersistedAgentSessionRecord(legacy)).toEqual({ + record: { ...record, lease: { ...record.lease, claimStatus: 'conflicted' } }, + normalized: true + }) + }) +}) + +describe('what this build writes', () => { + it('stores Claude and Codex handles in the typed form older builds read', () => { + const claude = claudeProviderHandle('sess-1', 'leaf-1') + const codex = codexProviderHandle('thread-1') + expect(encodePersistedAgentSessionProviderHandle(claude)).toEqual({ + provider: 'claude', + sessionId: 'sess-1', + leafUuid: 'leaf-1' + }) + expect(encodePersistedAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toEqual( + { provider: 'claude', sessionId: 'sess-1', leafUuid: null } + ) + expect(encodePersistedAgentSessionProviderHandle(codex)).toEqual({ + provider: 'codex', + threadId: 'thread-1' + }) + for (const handle of [claude, codex]) { + const stored = encodePersistedAgentSessionProviderHandle(handle) + expect(olderBuildReadsHandle(stored)).toBe(true) + expect(decodePersistedAgentSessionProviderHandle(stored)).toEqual(handle) + } + }) + + it('keeps the persisted key and root strings older builds derived', () => { + // Fork seeds, superseded creations and resume offers store these; a new spelling strands them. + const claude = claudeProviderHandle('sess-1', 'leaf-1') + expect(agentSessionProviderHandleKey(claude)).toBe('claude:["sess-1","leaf-1"]') + expect(agentSessionProviderHandleKey(claudeProviderHandle('sess-1', null))).toBe( + 'claude:["sess-1",null]' + ) + expect(agentSessionProviderHandleRoot(claude)).toBe('claude:"sess-1"') + expect(agentSessionProviderHandleKey(codexProviderHandle('t'))).toBe('codex:"t"') + expect(agentSessionProviderHandleRoot(codexProviderHandle('t'))).toBe('codex:"t"') + }) + + it('round-trips another transport in the neutral form, which an older build refuses', () => { + const grok = { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-session-1', + providerData: '{"cwd":"/repo"}' + } + const stored = encodePersistedAgentSessionProviderHandle(grok) + expect(stored).toEqual(grok) + expect(decodePersistedAgentSessionProviderHandle(JSON.parse(JSON.stringify(stored)))).toEqual( + grok + ) + // Unknown providers must never impersonate Codex on an older build. + expect(olderBuildReadsHandle(stored)).toBe(false) + expect(isAgentSessionHandleProvider(grok.agent)).toBe(false) + expect(agentSessionWireProviderHandle(grok)).toBeNull() + // Root, not provider data: provider data is resume state, not identity. + expect(agentSessionProviderHandleRoot(grok)).toBe('acp/grok:"acp-session-1"') + expect(agentSessionProviderHandleKey(grok)).toBe(agentSessionProviderHandleRoot(grok)) + }) + + it('refuses a stored handle that is not the one form its transport writes', () => { + for (const value of [ + // Claude or Codex in the neutral form: no build writes it. + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1' }, + { transport: 'codex-app-server', agent: 'codex', nativeId: 'thread-1' }, + // Both forms at once. + { provider: 'grok', transport: 'acp', agent: 'grok', nativeId: 'x' }, + { provider: 'claude', sessionId: 'sess-1' }, + { provider: 'claude', sessionId: 'sess-1', leafUuid: '' }, + { provider: 'codex', threadId: ' padded ' }, + { transport: 'a:b', agent: 'grok', nativeId: 'x' }, + { transport: 'acp', agent: 'grok', nativeId: 'x', providerData: '' }, + null + ]) { + expect(decodePersistedAgentSessionProviderHandle(value)).toBeNull() + } + }) +}) + +describe('the wire and journal forms', () => { + it('maps the attach wire handle both ways for Claude and Codex', () => { + const claudeWire = { kind: 'claude', sessionId: 'sess-1', leafUuid: null } as const + const codexWire = { kind: 'codex', threadId: 'thread-1' } as const + expect(agentSessionProviderHandleFromWire(claudeWire)).toEqual( + claudeProviderHandle('sess-1', null) + ) + expect(agentSessionWireProviderHandle(agentSessionProviderHandleFromWire(claudeWire))).toEqual( + claudeWire + ) + expect(agentSessionWireProviderHandle(agentSessionProviderHandleFromWire(codexWire))).toEqual( + codexWire + ) + }) + + it('records the journal rows exactly as before, and another transport as opaque', () => { + expect( + agentSessionJournalProviderHandle(claudeProviderHandle('sess-1', 'leaf-1'), 'claude') + ).toEqual({ kind: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }) + expect(agentSessionJournalProviderHandle(codexProviderHandle('t'), 'codex')).toEqual({ + kind: 'codex', + threadId: 't' + }) + expect(agentSessionJournalProviderHandle(null, 'codex')).toEqual({ + kind: 'opaque', + agent: 'codex', + value: 'pending' + }) + expect( + agentSessionJournalProviderHandle({ transport: 'acp', agent: 'grok', nativeId: 's' }, 'grok') + ).toEqual({ kind: 'opaque', agent: 'grok', value: 's' }) + }) +}) diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts new file mode 100644 index 000000000000..ca6d242d24a6 --- /dev/null +++ b/src/shared/agent-session-record-stored-form.ts @@ -0,0 +1,41 @@ +/** + * An agent-session record between its stored row and memory. The stored row keeps what older + * builds read: Claude and Codex handles in their typed form and lease values only they wrote. + */ + +import { + leaseCarriesLegacyHandoffValues, + normalizeLegacyHandoffLease, + type PersistedAgentSessionRecord +} from './agent-session-legacy-handoff-lease' +import { + decodePersistedAgentSessionProviderHandleChain, + encodePersistedAgentSessionProviderHandleChain +} from './agent-session-provider-handle' +import type { AgentSessionRecord } from './agent-session-record' + +/** The in-memory record, plus whether decode changed anything the store must write back. A + * handle's stored form is not such a change: every build writes it the same way. */ +export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionRecord): { + record: AgentSessionRecord + normalized: boolean +} { + const providerHandleChain = decodePersistedAgentSessionProviderHandleChain( + record.providerHandleChain + ) + if (!providerHandleChain) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + record: { ...record, providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, + normalized: leaseCarriesLegacyHandoffValues(record.lease) + } +} + +/** The record as a row stores it; Claude and Codex handles keep the typed form older builds read. */ +export function encodeAgentSessionRecord(record: AgentSessionRecord): PersistedAgentSessionRecord { + return { + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) + } +} diff --git a/src/shared/agent-session-record.test-fixture.ts b/src/shared/agent-session-record.test-fixture.ts index 3e2270d368b1..68a190fbbcae 100644 --- a/src/shared/agent-session-record.test-fixture.ts +++ b/src/shared/agent-session-record.test-fixture.ts @@ -5,6 +5,7 @@ import { type AgentSessionLease, type AgentSessionRecord } from './agent-session-record' +import { claudeProviderHandle } from './agent-session-provider-handle-encoding' const OWNER_PROCESS = { hostId: 'local', @@ -56,7 +57,7 @@ export function agentSessionRecordFixture( origin: 'created', mintedAtFence: lease.runtimeFence, observedAt: 1_000, - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + handle: claudeProviderHandle('provider-session-alpha-1', null) } ], accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index b8280cf332da..963d79b14699 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -21,10 +21,11 @@ import { type AgentSessionConversationCommandRecord } from './agent-session-conversation-command' import { - isAgentSessionProviderHandleChain, + decodePersistedAgentSessionProviderHandleChain, type AgentSessionHandleProvider, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' +import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const @@ -333,8 +334,8 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe ) } -/** The on-disk shape, which still admits the removed terminal handoff's lease values. Decode - * through `normalizeLegacyHandoffRecord` before anything reads the lease. */ +/** 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. */ export function isPersistedAgentSessionRecord( value: unknown ): value is PersistedAgentSessionRecord { @@ -347,7 +348,6 @@ export function isPersistedAgentSessionRecord( isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && (record.provider === 'claude' || record.provider === 'codex') && - isAgentSessionProviderHandleChain(record.providerHandleChain) && isAgentSessionAccountHome(record.accountHome) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && @@ -365,9 +365,12 @@ export function isPersistedAgentSessionRecord( return false } const validated = record as AgentSessionRecord - const head = validated.providerHandleChain.at(-1) + // The row holds stored handles; validate the chain they decode to. + const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) + const head = chain?.at(-1) return ( - validated.providerHandleChain.every((link) => link.handle.provider === validated.provider) && + chain !== null && + chain.every((link) => agentSessionProviderHandleBelongsTo(link.handle, validated.provider)) && (validated.lease.claimStatus !== 'live' || (validated.lease.ownerProcess !== null && head?.linkId === validated.lease.provenHandleLinkId && diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index 27f00644ec1b..568b390dadb8 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -73,6 +73,7 @@ import { StructuredAgentSessionStatusBridge } from '../../src/renderer/src/compo import { resetStructuredAgentSessionStatusFeedsForTests } from '../../src/renderer/src/runtime/structured-agent-session-status-feed' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { testEventSinkLogging } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' const SESSION = 'codex-child-approval' const CODEX_THREAD = 'thread-parent' @@ -139,7 +140,7 @@ async function openHost() { workspaceId: 'wt-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_THREAD } + providerHandle: codexProviderHandle(CODEX_THREAD) }, now: tick, stateDirectory: join(root, SESSION) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index b31c8f008cd1..73f634459063 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -6,8 +6,10 @@ import { AGENT_JOURNAL_THREAD_SCOPE, AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalItemIdentity, - type AgentSessionJournalIdentity + type AgentSessionJournalIdentity, + type AgentSessionJournalProviderHandle } from '../../../src/shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import Database from '../../../src/main/sqlite/sync-database' import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' import { @@ -33,6 +35,16 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} + +/** The identity as builds before the neutral provider handle took it. */ +type OlderJournalIdentity = Omit & { + providerHandle: AgentSessionJournalProviderHandle +} + +const OLDER_IDENTITY: OlderJournalIdentity = { + ...IDENTITY, providerHandle: { kind: 'codex', threadId: 'thread-1' } } @@ -163,7 +175,7 @@ type OlderJournal = { type OlderOpener = { open: (options: { - identity: AgentSessionJournalIdentity + identity: OlderJournalIdentity stateDirectory: string }) => Promise closeAll: () => Promise @@ -203,7 +215,7 @@ test("an older build opens this build's journal writable and appends to it; the ) const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() try { - const downgraded = await older.open({ identity: IDENTITY, stateDirectory: directory }) + const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) expect(downgraded.isReadOnly).toBe(false) expect(downgraded.cursor()).toEqual(wrote.cursor) expect(itemIds(downgraded)).toEqual(wrote.items) @@ -279,7 +291,7 @@ test("this build keeps a newer build's row kind and goes read-only; a build befo ) const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() try { - await older.open({ identity: IDENTITY, stateDirectory: directory }) + await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) } finally { await older.closeAll() } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 1980ed95c55a..d427eb5109a8 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -64,6 +64,7 @@ import { } from './versioned-agent-session-wire' import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -561,7 +562,7 @@ describe('cross-version structured agent sessions', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -752,7 +753,7 @@ describe('cross-version structured agent sessions', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), // A restarted host re-proves the thread it inherited; only the first // owner of a session may claim to have created it. origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index b7734f2759d2..0220a33e59a7 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -33,6 +33,7 @@ import { resetStructuredAgentSessionReadOwnersForTests } from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' const SESSION = 'cursor-body-regression' const target = { kind: 'local' } as const @@ -56,7 +57,7 @@ async function fixture() { workspaceId: 'folder-workspace', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'journal') }) diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 4968af98a934..abf2f994004a 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -26,6 +26,7 @@ import { readWorktreeStructuredActivationInventory } from '../../src/renderer/sr import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' const WORKTREE = 'repo-1::/workspace/repo' @@ -44,7 +45,7 @@ function openHost(): void { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW From d8b831ee649106d56b202ed3d68346f1769eaf2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:59:29 -0700 Subject: [PATCH 02/45] fix(native-chat): derive journal-row provider handles from the journal identity The journal row converter now takes the identity every caller already holds, so a row's handle has one obvious constructor. Tests that wrote the in-memory handle straight into journal rows now build it through that converter, and the processless Claude fixture names a not-yet-proved handle as null. --- ...ed-session-acquisition-processless.test.ts | 2 +- .../journal-epoch-replacement.ts | 5 +---- .../journal-epoch-rollover.ts | 5 +---- .../journal-row-builders.ts | 2 +- .../journal-stop-and-resume-rows.test.ts | 7 ++++-- .../journal-submission-queued-link.test.ts | 7 ++++-- .../journal-unknown-row-kind.test.ts | 7 ++++-- ...ession-history-forward-read-budget.test.ts | 7 ++++-- .../agent-session-provider-handle-encoding.ts | 18 ++++++++++----- .../agent-session-record-stored-form.test.ts | 22 +++++++++++++------ 10 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index eb4babdb9aca..a75f82b72db5 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -13,7 +13,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'local', agent: 'claude', - providerHandle: { kind: 'opaque', agent: 'claude', value: 'pending' } + providerHandle: null } describe('Claude structured processless acquisition', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index b2754e33acaf..c378e343d7a7 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -59,10 +59,7 @@ export function replaceJournalEpoch(input: { const epochRow: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: agentSessionJournalProviderHandle( - input.identity.providerHandle, - input.identity.agent - ), + providerHandle: agentSessionJournalProviderHandle(input.identity), ...journalRowBase(epoch, 1, input.fence, input.now()) } const rows: JournalRow[] = [epochRow] diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 26296f89d109..a1eaac157a53 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -33,10 +33,7 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: agentSessionJournalProviderHandle( - input.identity.providerHandle, - input.identity.agent - ), + providerHandle: agentSessionJournalProviderHandle(input.identity), // Carries no body: an older host must keep reading a turn-free session past row 1. v: journalRowSchemaVersion([]), epoch: input.epoch, diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 2cf3f60b0e6f..ed40d4909517 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -87,7 +87,7 @@ export function journalSubmissionRowBuilder( const queuedMessageId = consume?.messageId ?? input.queuedMessageId return buildJournalSubmissionRow({ state: state(), - providerHandle: agentSessionJournalProviderHandle(identity.providerHandle, identity.agent), + providerHandle: agentSessionJournalProviderHandle(identity), ...input, ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), seq, diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index a7fedb6a0a10..a5d17a77069c 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -23,7 +23,10 @@ import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-s', @@ -65,7 +68,7 @@ function epochWith( { kind: 'epoch', reason, - providerHandle: IDENTITY.providerHandle, + providerHandle: agentSessionJournalProviderHandle(IDENTITY), ...journalRowBase(EPOCH, 1, 1, 1) } ] diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index 1159a37ef33f..f9588b71ba3a 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -15,7 +15,10 @@ import { createJournalReducerState, applyJournalRow } from './journal-reducer' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + claudeProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', @@ -133,7 +136,7 @@ describe('the persisted row', () => { ts: 1, clientMessageId: 'handoff-1', payloadFingerprint: 'fp', - providerHandle: IDENTITY.providerHandle, + providerHandle: agentSessionJournalProviderHandle(IDENTITY), body: BODY, queuedMessageId: 'draft-1' } diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index 55a8a0460858..ea9bb14ef971 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -20,7 +20,10 @@ import { openTestJournalHostDatabase } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-newer-kind', @@ -106,7 +109,7 @@ async function journalWithOneItem(): Promise { * fails to compile. */ function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } - const providerHandle = IDENTITY.providerHandle + const providerHandle = agentSessionJournalProviderHandle(IDENTITY) return { epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, item: { diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 1a1e7fda2cd8..f94624c26c6c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -20,7 +20,10 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { readAgentSessionHistory } from './agent-session-history-page' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const identity: AgentSessionJournalIdentity = { sessionId: 'bounded-catch-up', @@ -56,7 +59,7 @@ async function seedJournal(count: number) { kind: 'epoch', seq: 1, reason: 'session_created', - providerHandle: identity.providerHandle + providerHandle: agentSessionJournalProviderHandle(identity) }) for (let index = 0; index < count; index += 1) { insertTestJournalRow(db, identity.sessionId, { diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index e15cc5339d86..77a71a32b14e 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -14,7 +14,10 @@ import type { AgentSessionProviderHandle, AgentSessionProviderHandleNamespace } from './agent-session-provider-handle' -import type { AgentSessionJournalProviderHandle, AgentType } from './agent-session-journal-types' +import type { + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle +} from './agent-session-journal-types' export const CLAUDE_STRUCTURED_HANDLE_NAMESPACE = { transport: 'claude-sdk', @@ -267,13 +270,18 @@ export function agentSessionWireProviderHandle( /** What a journal row records before the provider has proved any handle. */ export const AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE = 'pending' -/** The handle a journal row records. Write-only: rows keep it for diagnosis, nothing reads it. */ +/** The handle a journal row records for this identity. Write-only: rows keep it for diagnosis, + * nothing reads it. */ export function agentSessionJournalProviderHandle( - handle: AgentSessionProviderHandle | null, - agent: AgentType + identity: Pick ): AgentSessionJournalProviderHandle { + const handle = identity.providerHandle if (!handle) { - return { kind: 'opaque', agent, value: AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE } + return { + kind: 'opaque', + agent: identity.agent, + value: AGENT_SESSION_PENDING_PROVIDER_HANDLE_VALUE + } } return ( agentSessionWireProviderHandle(handle) ?? { diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 5070ae67d47e..2107740219e5 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -222,19 +222,27 @@ describe('the wire and journal forms', () => { it('records the journal rows exactly as before, and another transport as opaque', () => { expect( - agentSessionJournalProviderHandle(claudeProviderHandle('sess-1', 'leaf-1'), 'claude') + agentSessionJournalProviderHandle({ + agent: 'claude', + providerHandle: claudeProviderHandle('sess-1', 'leaf-1') + }) ).toEqual({ kind: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }) - expect(agentSessionJournalProviderHandle(codexProviderHandle('t'), 'codex')).toEqual({ - kind: 'codex', - threadId: 't' - }) - expect(agentSessionJournalProviderHandle(null, 'codex')).toEqual({ + expect( + agentSessionJournalProviderHandle({ + agent: 'codex', + providerHandle: codexProviderHandle('t') + }) + ).toEqual({ kind: 'codex', threadId: 't' }) + expect(agentSessionJournalProviderHandle({ agent: 'codex', providerHandle: null })).toEqual({ kind: 'opaque', agent: 'codex', value: 'pending' }) expect( - agentSessionJournalProviderHandle({ transport: 'acp', agent: 'grok', nativeId: 's' }, 'grok') + agentSessionJournalProviderHandle({ + agent: 'grok', + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 's' } + }) ).toEqual({ kind: 'opaque', agent: 'grok', value: 's' }) }) }) From a00a327196b3a0b54f29a2c5a7f853cedd780fac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:16:02 -0700 Subject: [PATCH 03/45] fix(native-chat): refuse a stored provider handle written in both forms A typed Claude or Codex handle that also carries the neutral form's transport, agent, native id or provider data named two identities; it was read as Claude or Codex and the next write dropped the other one. Such a row now stays unreadable and is set aside untouched. --- .../agent-session-record-store.test.ts | 23 +++++++++ .../agent-session-provider-handle-encoding.ts | 11 +++++ .../agent-session-record-stored-form.test.ts | 48 ++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 078f9b6d0eca..b03317f144d0 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -743,6 +743,29 @@ describe('claim keys and unreadable rows', () => { expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) + it('sets aside a row whose handle is in both stored forms and never rewrites it', async () => { + const first = await open() + await establishOwner(first) + let ambiguous: PersistedAgentSessionRecord | undefined + await editPersistedTestAgentSessionStore(directory, (persisted) => { + ambiguous = persisted.records['session-alpha'] + Object.assign(ambiguous.providerHandleChain[0].handle, { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-thread', + providerData: 'resume-token' + }) + }) + const reopened = await open() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + // Another chat's write must carry the set-aside row through untouched. + await establishOwner(reopened, { sessionId: 'session-beta', claimKeyId: 'key-2' }) + expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) + expect((await readPersistedTestAgentSessionStore(directory)).records['session-alpha']).toEqual( + ambiguous + ) + }) + it('refuses to write a store a newer Orca wrote, with the refusal clients print as "update"', async () => { await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index 77a71a32b14e..da799fa26798 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -176,12 +176,23 @@ export function encodePersistedAgentSessionProviderHandle( } } +/** The neutral form's discriminator and identity fields; a typed stored handle carries none of them. */ +const NEUTRAL_HANDLE_FIELDS = ['transport', 'agent', 'nativeId', 'providerData'] as const + export function decodePersistedAgentSessionProviderHandle( value: unknown ): AgentSessionProviderHandle | null { if (typeof value !== 'object' || value === null) { return null } + // Why: a row in both forms names two identities; reading it as either would erase the other on + // the next write, so it stays unreadable. + if ( + Object.hasOwn(value, 'provider') && + NEUTRAL_HANDLE_FIELDS.some((field) => Object.hasOwn(value, field)) + ) { + return null + } const provider = 'provider' in value ? value.provider : undefined if (provider === 'claude') { const sessionId = 'sessionId' in value ? value.sessionId : undefined diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 2107740219e5..3c4381a2f432 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -191,8 +191,26 @@ describe('what this build writes', () => { // Claude or Codex in the neutral form: no build writes it. { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1' }, { transport: 'codex-app-server', agent: 'codex', nativeId: 'thread-1' }, - // Both forms at once. + // Both forms at once: reading either identity would erase the other. { provider: 'grok', transport: 'acp', agent: 'grok', nativeId: 'x' }, + { + provider: 'codex', + threadId: 'thread-1', + transport: 'acp', + agent: 'grok', + nativeId: 'acp-thread', + providerData: 'resume-token' + }, + { provider: 'codex', threadId: 'thread-1', nativeId: 'other-thread' }, + { + provider: 'claude', + sessionId: 'sess-1', + leafUuid: null, + transport: 'acp', + agent: 'grok', + nativeId: 'other' + }, + { provider: 'claude', sessionId: 'sess-1', leafUuid: null, providerData: 'leaf-2' }, { provider: 'claude', sessionId: 'sess-1' }, { provider: 'claude', sessionId: 'sess-1', leafUuid: '' }, { provider: 'codex', threadId: ' padded ' }, @@ -203,6 +221,34 @@ describe('what this build writes', () => { expect(decodePersistedAgentSessionProviderHandle(value)).toBeNull() } }) + + it('still reads a typed handle that carries an unrelated field', () => { + expect( + decodePersistedAgentSessionProviderHandle({ provider: 'codex', threadId: 't', note: 1 }) + ).toEqual(codexProviderHandle('t')) + expect( + decodePersistedAgentSessionProviderHandle({ + provider: 'claude', + sessionId: 'sess-1', + leafUuid: 'leaf-1', + note: 1 + }) + ).toEqual(claudeProviderHandle('sess-1', 'leaf-1')) + }) + + it.each([ + ['Claude', STORED_CLAUDE_ROW], + ['Codex', STORED_CODEX_ROW] + ])('refuses a whole %s row whose handle also names a neutral identity', (_name, row) => { + const stored = JSON.parse(row) + Object.assign(stored.providerHandleChain[0].handle, { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-thread', + providerData: 'resume-token' + }) + expect(isPersistedAgentSessionRecord(stored)).toBe(false) + }) }) describe('the wire and journal forms', () => { From 2667173fed98998b8bf49307287cad1561b7414a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:34:54 -0700 Subject: [PATCH 04/45] refactor(native-chat): route structured agents through registered definitions The structured-chat shared layer named Claude and Codex in a closed router type, in option reads, in frame classification, and in renderer label and validation branches, so adding an agent meant editing shared types. Each agent now has a definition (capabilities, resting option rules) owned by its own module; the router is a registry of definition + adapter pairs built at runtime setup, and shared code reads the definition instead of the name. No behavior change for Claude or Codex; no wire or stored shape change. --- ...claude-context-usage-unloaded-turn.test.ts | 12 +- .../claude-structured-agent-definition.ts | 24 +++ .../claude-structured-launch-resolution.ts | 6 +- .../claude-structured-session-adapter.ts | 9 - .../codex-structured-agent-definition.ts | 25 +++ .../codex-structured-launch-resolution.ts | 6 +- .../codex/codex-structured-session-adapter.ts | 3 - .../codex-structured-session-close.test.ts | 4 +- .../agent-model-catalog-fingerprint.ts | 6 +- .../agent-model-catalog-service.ts | 12 +- .../provider-frame-disposition.ts | 63 +++--- .../structured-agent-definition.ts | 37 ++++ ...nt-session-adapter-router-registry.test.ts | 182 ++++++++++++++++++ ...ent-session-adapter-router-test-support.ts | 18 ++ ...tured-agent-session-adapter-router.test.ts | 28 +-- ...structured-agent-session-adapter-router.ts | 106 ++++++---- .../structured-agent-session-adapter.ts | 8 +- ...agent-session-mutation-owed-import.test.ts | 6 +- .../structured-agent-session-options-read.ts | 29 ++- ...ructured-agent-session-provider-support.ts | 10 +- ...uctured-agent-session-repeat-press.test.ts | 3 +- ...ructured-agent-session-thread-goal.test.ts | 33 +++- .../structured-agent-session-thread-goal.ts | 2 +- ...structured-agent-session-create-support.ts | 25 ++- .../structured-agent-session-at-rest.test.ts | 8 +- .../structured-agent-session-runtime.ts | 14 +- .../structured-claude-pending-rewind.test.ts | 12 +- .../NativeChatStructuredSession.tsx | 2 +- .../use-host-model-catalog-upgrade.ts | 3 +- .../use-structured-agent-session-options.ts | 5 +- .../use-structured-agent-session.ts | 2 +- .../structured-agent-session-launch-label.ts | 4 +- ...ctured-agent-session-launch-persistence.ts | 7 +- ...tructured-agent-session-provisional-tab.ts | 8 +- src/shared/agent-session-account-home.test.ts | 37 ++++ src/shared/agent-session-account-home.ts | 45 +++++ src/shared/agent-session-capabilities.ts | 39 ++++ ...ent-session-option-catalog-claude-codex.ts | 1 + .../agent-session-option-catalog-types.ts | 3 + src/shared/agent-session-record.ts | 16 +- .../structured-agent-session-mutation.ts | 12 +- 41 files changed, 682 insertions(+), 193 deletions(-) create mode 100644 src/main/claude/claude-structured-agent-definition.ts create mode 100644 src/main/codex/codex-structured-agent-definition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-definition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts create mode 100644 src/shared/agent-session-account-home.test.ts create mode 100644 src/shared/agent-session-account-home.ts create mode 100644 src/shared/agent-session-capabilities.ts diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 5473719ce114..d98d1e68371e 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -29,6 +29,7 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -171,7 +172,9 @@ describe('context usage for a turn row outside the loaded page', () => { const state = attachTail(journal, 3) expect(state.hasOlder).toBe(true) expect(hasTurnRow(state)).toBe(false) - const options = await readOptions(journal, { recordsContextUsage: () => true }) + const options = await readOptions(journal, { + capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities + }) expect(options.contextUsage?.current).toEqual( latestStructuredAgentContextFacts(journal.snapshot().items) @@ -213,7 +216,9 @@ describe('context usage for a turn row outside the loaded page', () => { expect(hasTurnRow(live)).toBe(false) expect(live.unloadedTurnRevisions).toBe(1) - const options = await readOptions(journal, { recordsContextUsage: () => true }) + const options = await readOptions(journal, { + capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities + }) expect( selectStructuredAgentContextUsage(live.items, options.contextUsage?.current) ).toMatchObject({ usedTokens: 250_000, windowTokens: 1_000_000 }) @@ -227,7 +232,8 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-b', 3_000, []) await settle() const hostAnswer = async () => - (await readOptions(journal, { recordsContextUsage: () => true })).contextUsage?.current + (await readOptions(journal, { capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities })) + .contextUsage?.current // The client reads the host once per turn, and again whenever its window loses a turn row. let state = attachTail(journal, 200) let host = await hostAnswer() diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts new file mode 100644 index 000000000000..ca37f7abfad3 --- /dev/null +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -0,0 +1,24 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { isClaudeStructuredOptionKey } from './claude-structured-options' +import { claudeFallbackModelOptions } from './claude-structured-session-options' + +export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { + agent: 'claude', + capabilities: { + // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. + rewind: false, + compact: true, + threadGoal: false, + // A session at rest still reports the usage its journal recorded. + contextUsage: true, + imagePrompts: true, + steering: 'inject', + // The permission mode is a launch flag the CLI enforces. + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: isClaudeStructuredOptionKey, + fallbackModels: claudeFallbackModelOptions, + effortDefaultsToModel: true + } +} diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 979b5ae5963e..95e43e2a276b 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -34,6 +34,7 @@ import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -250,8 +251,9 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } - if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { - throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + const pinned = agentConfigDirectoryVariable('claude') + if (record.accountHome.variable !== pinned) { + throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) } // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 746d603308f1..536d4be82395 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -76,12 +76,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda supportsLocation = supportsClaudeStructuredLocation - // Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork. - rewindSupport: NonNullable = () => ({ - supported: false, - reason: 'unsupported' - }) - acquire = (input: StructuredAgentSessionAcquireInput): Promise => { this.settledExitErrors.delete(input.identity.sessionId) return acquireClaudeSession({ @@ -254,9 +248,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - // Provider-level: a session at rest still reports the usage its journal recorded. - recordsContextUsage = (): boolean => true - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) ] diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts new file mode 100644 index 000000000000..8aab5137b114 --- /dev/null +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -0,0 +1,25 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { isCodexTurnOptionKey } from './codex-structured-turn-start' + +export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { + agent: 'codex', + capabilities: { + // A thread with legacy, unpaginated history narrows this to unsupported once it runs. + rewind: true, + compact: true, + // A goal change at rest starts the agent first. + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + // Approval and sandbox policy ride on the thread; the app-server enforces them. + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: isCodexTurnOptionKey, + // No built-in list: the client fills the current model from its own unknown-model defaults. + fallbackModels: () => null, + // A running child answers only the effort its thread reported, never the model's default. + effortDefaultsToModel: false + } +} diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 19945c9a6c89..b48cc4cae113 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -15,6 +15,7 @@ import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -85,8 +86,9 @@ export function createCodexStructuredLaunchResolver( ) { throw new Error('codex structured sessions require Windows process creation-time proof') } - if (accountHome.variable !== 'CODEX_HOME') { - throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) + const pinned = agentConfigDirectoryVariable('codex') + if (accountHome.variable !== pinned) { + throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`) } const { command, environment } = await resolveCodexStructuredInvocation(deps) // `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cdc..8cf8466fdd61 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -261,9 +261,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.requestTimeoutMs ) - // Provider-level: a goal change at rest starts the agent first. - supportsThreadGoal = (): boolean => true - answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerCodexStructuredPrompt({ request, sessions: this.sessions }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index cb443a157e02..e9a16a2a3c96 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -16,7 +16,7 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const THREAD = 'thread-1' @@ -212,7 +212,7 @@ describe('Codex structured session close lifecycle', () => { it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => { const { adapter, connections, events } = adapterFixture() - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude: claudeAdapterStub(), codex: adapter }, async () => {} ) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index 28b1f8729d3c..70c59acd77b4 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,6 +4,8 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' +import { agentConfigDirectoryVariable } from '../../../shared/agent-session-account-home' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' /** * Everything that changes which models a listing can answer with: the agent, @@ -55,7 +57,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: 'claude' | 'codex', + agent: AgentSessionHandleProvider, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { @@ -65,7 +67,7 @@ export function agentModelCatalogSessionAccess( store, fingerprint: agentModelCatalogFingerprint({ agent, - accountHomeVariable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + accountHomeVariable: agentConfigDirectoryVariable(agent), accountHomePath, wslDistro: null }), diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 8c794331a2ce..a8cc70ade7c9 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -1,5 +1,9 @@ import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -16,9 +20,7 @@ export type AgentModelCatalogServiceDeps = { /** The account home a structured launch for this agent would pin right now — * the SAME resolver the create path fills `record.accountHome` with, so a * record-less read can never answer from another account's listing. */ - resolveAccountHome: ( - agent: 'claude' | 'codex' - ) => Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> + resolveAccountHome: (agent: AgentSessionHandleProvider) => Promise /** Session-less listers, one per agent that has one on this host. */ probes?: Partial> /** Whether the workspace's own config could pick a model other than the listed default. */ @@ -96,7 +98,7 @@ export function createAgentModelCatalogService( // Probes spawn natively; a WSL-pinned record has no host-side lister. accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null } else { - let resolved: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string } + let resolved: AgentSessionAccountHome try { resolved = await deps.resolveAccountHome(params.agent) } catch { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 1b89afd3997e..56c5244e2ff2 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -279,24 +279,44 @@ export function isDeltaProviderFrameKind(kind: string): boolean { return notificationKind(kind).toLowerCase().endsWith('delta') } -function catalogClassification( - provider: string, - kind: string -): ProviderFrameClassification | undefined { - if (provider === 'codex') { - const item = itemKind(kind) - if (item !== null) { - return CODEX_ITEM_CLASSIFICATIONS[item] +const CODEX_NOTIFICATION_CLASSIFICATIONS: Readonly> = + PROVIDER_FRAME_CLASSIFICATIONS.codex +const CLAUDE_FRAME_CLASSIFICATIONS: Readonly> = + PROVIDER_FRAME_CLASSIFICATIONS.claude + +/** Each provider's own table, by provider id: an unlisted provider has none, so every frame it + * sends that no rule above claims stays a visible row. */ +const PROVIDER_FRAME_CATALOGS: ReadonlyMap< + string, + (kind: string, payload: unknown) => ProviderFrameClassification | undefined +> = new Map< + keyof ProviderFrameClassificationTable, + (kind: string, payload: unknown) => ProviderFrameClassification | undefined +>([ + [ + 'codex', + (kind) => { + const item = itemKind(kind) + if (item !== null) { + return CODEX_ITEM_CLASSIFICATIONS[item] + } + return CODEX_NOTIFICATION_CLASSIFICATIONS[notificationKind(kind)] } - return PROVIDER_FRAME_CLASSIFICATIONS.codex[ - notificationKind(kind) as CodexAppServerNotificationMethod - ] - } - if (provider === 'claude') { - return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] - } - return undefined -} + ], + [ + 'claude', + (kind, payload) => { + if (kind === 'message:result') { + const subtype = + typeof payload === 'object' && payload !== null && 'subtype' in payload + ? payload.subtype + : undefined + return subtype === 'success' ? 'status-chrome' : 'error-surface' + } + return CLAUDE_FRAME_CLASSIFICATIONS[kind] + } + ] +]) export function classifyProviderFrame( provider: string, @@ -312,12 +332,5 @@ export function classifyProviderFrame( if (isDeltaProviderFrameKind(kind)) { return 'stream-into-item' } - if (provider === 'claude' && kind === 'message:result') { - const subtype = - typeof payload === 'object' && payload !== null - ? (payload as Record).subtype - : undefined - return subtype === 'success' ? 'status-chrome' : 'error-surface' - } - return catalogClassification(provider, kind) ?? 'timeline-substantive' + return PROVIDER_FRAME_CATALOGS.get(provider)?.(kind, payload) ?? 'timeline-substantive' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts new file mode 100644 index 000000000000..7578c42463c3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -0,0 +1,37 @@ +// What the host knows about a structured agent before any session of it runs. +// +// Each agent's own module declares its definition; shared host code reads the definition and never +// branches on the agent's name. The router routes sessions to adapters registered with these +// definitions, and at-rest reads with no adapter in hand look one up by agent here. + +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' + +export type StructuredAgentDefinition = { + /** The Orca agent whose sessions this definition describes. */ + agent: string + capabilities: AgentSessionCapabilities + /** How a session's options read and change while no child runs. */ + restingOptions: { + /** Whether the agent takes a pick of this option key. */ + acceptsKey: (key: string) => boolean + /** The models a running child falls back to with no catalog; null when it has none. */ + fallbackModels: () => AgentSessionModelOption[] | null + /** An unpicked effort reads as the model's default effort, as a running child reports it. */ + effortDefaultsToModel: boolean + } +} + +const DEFINITIONS: ReadonlyMap = new Map( + [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].map((definition) => [ + definition.agent, + definition + ]) +) + +/** The definition of an agent this build drives in structured sessions; null for any other. */ +export function structuredAgentDefinition(agent: string): StructuredAgentDefinition | null { + return DEFINITIONS.get(agent) ?? null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts new file mode 100644 index 000000000000..08c77443d7ac --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { NO_AGENT_SESSION_CAPABILITIES } from '../../../shared/agent-session-capabilities' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { + AgentSessionAcquisition, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { + structuredAgentDefinition, + type StructuredAgentDefinition +} from './structured-agent-definition' + +const LOCAL: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +function identity(sessionId: string, agent: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent, + providerHandle: claudeProviderHandle('provider-session-1', null) + } +} + +function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { + return { + process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: claudeProviderHandle('provider-session-1', null), + origin: 'created', + mintedAtFence: fence, + observedAt: 1 + } + } +} + +function fakeAdapter( + overrides: Partial = {} +): StructuredAgentSessionAdapter { + return { + supportsLocation: () => true, + acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + ...overrides + } +} + +/** An agent this build does not ship, declared the way a new adapter would declare itself. */ +const PILOT: StructuredAgentDefinition = { + agent: 'grok', + capabilities: { + ...NO_AGENT_SESSION_CAPABILITIES, + steering: 'queue', + approvalEnforcement: 'orca' + }, + restingOptions: { + acceptsKey: () => false, + fallbackModels: () => null, + effortDefaultsToModel: false + } +} + +describe('StructuredAgentSessionAdapterRouter registry', () => { + it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { + const pilot = fakeAdapter() + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: PILOT, adapter: pilot }], + async () => {} + ) + + expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) + expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) + await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) + expect(pilot.acquire).toHaveBeenCalledOnce() + await expect( + router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) + ).rejects.toThrow('structured sessions do not support claude') + }) + + it('refuses two registrations for one agent', () => { + expect( + () => + new StructuredAgentSessionAdapterRouter( + [ + { definition: PILOT, adapter: fakeAdapter() }, + { definition: PILOT, adapter: fakeAdapter() } + ], + async () => {} + ) + ).toThrow('structured agent grok is registered twice') + }) + + it('answers capabilities from the live owner, else from the agent named at rest', async () => { + const router = new StructuredAgentSessionAdapterRouter( + [ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }, + { definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() } + ], + async () => {} + ) + + expect(router.capabilities('session-1')).toBeUndefined() + expect(router.capabilities('session-1', 'codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) + expect(router.capabilities('session-1', 'grok')).toBeUndefined() + await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) + // A live session answers for its own agent, whatever a caller names. + expect(router.capabilities('session-1', 'codex')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) + }) + + it('lets a session narrow a declared rewind but never widen an undeclared one', () => { + const narrowing = fakeAdapter({ + rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) + }) + const widening = fakeAdapter({ rewindSupport: () => ({ supported: true }) }) + const router = new StructuredAgentSessionAdapterRouter( + [ + { definition: CODEX_STRUCTURED_AGENT, adapter: narrowing }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: widening } + ], + async () => {} + ) + + expect(router.rewindSupport('session-1', 'codex')).toEqual({ + supported: false, + reason: 'history-not-paginated' + }) + expect(router.rewindSupport('session-1', 'claude')).toEqual({ + supported: false, + reason: 'unsupported' + }) + }) +}) + +describe('structured agent definitions', () => { + it('declares what Claude and Codex structured chats already did', () => { + expect(structuredAgentDefinition('claude')?.capabilities).toEqual({ + rewind: false, + compact: true, + threadGoal: false, + contextUsage: true, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + }) + expect(structuredAgentDefinition('codex')?.capabilities).toEqual({ + rewind: true, + compact: true, + threadGoal: true, + contextUsage: false, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + }) + expect(structuredAgentDefinition('grok')).toBeNull() + }) + + it('keeps each agent’s resting option rules with its definition', () => { + const claude = structuredAgentDefinition('claude')!.restingOptions + const codex = structuredAgentDefinition('codex')!.restingOptions + expect(claude.fallbackModels()?.length).toBeGreaterThan(0) + expect(codex.fallbackModels()).toBeNull() + expect(claude.effortDefaultsToModel).toBe(true) + expect(codex.effortDefaultsToModel).toBe(false) + expect(claude.acceptsKey('model')).toBe(true) + expect(codex.acceptsKey('model')).toBe(true) + expect(claude.acceptsKey('no-such-option')).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts new file mode 100644 index 000000000000..b0e39d2c84e8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support.ts @@ -0,0 +1,18 @@ +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' + +/** A router with this build's two agents registered under their real definitions. */ +export function claudeAndCodexRouter( + adapters: { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter }, + closeAdapters: () => Promise +): StructuredAgentSessionAdapterRouter { + return new StructuredAgentSessionAdapterRouter( + [ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude }, + { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex } + ], + closeAdapters + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 076e2de7540d..7f8ad5c91067 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -7,7 +7,7 @@ import type { AgentSessionAcquisition, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { @@ -51,7 +51,7 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { const failure = new Error('root exited') const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) @@ -71,7 +71,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { claude.closeSession = closeSession claude.dispatch = dispatch const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) @@ -96,7 +96,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { }) const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => {} ) @@ -129,7 +129,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) claude.dispatch = dispatch const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) const stopSession = router[method] @@ -156,7 +156,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, @@ -183,7 +183,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.stopEndsSession = () => true const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.stopEndsSession('session-1')).toBe(false) await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) @@ -194,7 +194,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.awaitStoppedRequestEnd = vi.fn(async () => undefined) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) await router.awaitStoppedRequestEnd('session-1', 5) expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled() @@ -207,7 +207,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { const claude = adapterOf(vi.fn(async () => true)) claude.routePromptCancel = () => ({ kind: 'stop' }) const codex = adapterOf(vi.fn(async () => false)) - const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined() await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) @@ -222,7 +222,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) const claude = adapterOf(vi.fn(async () => true)) claude.acquire = acquire - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) @@ -241,7 +241,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('keeps a per-session stop proof and reports no stop for a session it never routed', async () => { const claude = adapterOf(vi.fn(async () => true)) const closeAdapters = vi.fn(async () => undefined) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, closeAdapters ) @@ -266,7 +266,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('asks the adapters to release an unrouted session rather than answering from the close proof', async () => { const claudeRelease = vi.fn(async () => true) const codexRelease = vi.fn(async () => false) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude: adapterOf(claudeRelease), codex: adapterOf(codexRelease) }, async () => undefined ) @@ -284,7 +284,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const closeSession = vi.fn(async () => true) claude.dispatch = dispatch claude.closeSession = closeSession - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, vi.fn(async () => { throw failure @@ -322,7 +322,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { resolveAcquire = resolve }) ) - const router = new StructuredAgentSessionAdapterRouter( + const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b5d431c859a1..68720023fe65 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -6,41 +6,63 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' -type RoutedAgent = 'claude' | 'codex' -type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } +/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ +export type StructuredAgentRegistration = { + definition: StructuredAgentDefinition + adapter: StructuredAgentSessionAdapter +} + +type SessionRoute = { + registration: StructuredAgentRegistration + state: 'live' | 'stopped' +} +/** Routes each session to the adapter registered for its agent. Adding an agent is one more + * registration; nothing here names one. */ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { private readonly routes = new Map() + private readonly registrations: ReadonlyMap private allAdaptersClosed = false private closePromise: Promise | null = null constructor( - private readonly adapters: Record, + registrations: readonly StructuredAgentRegistration[], private readonly closeAdapters: () => Promise - ) {} + ) { + const byAgent = new Map() + for (const registration of registrations) { + if (byAgent.has(registration.definition.agent)) { + throw new Error(`structured agent ${registration.definition.agent} is registered twice`) + } + byAgent.set(registration.definition.agent, registration) + } + this.registrations = byAgent + } supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { - const adapter = this.adapterForAgent(agent) + const adapter = this.registrations.get(agent)?.adapter return adapter ? (adapter.supportsLocation?.(location) ?? false) : false } supportsLocation = (location: AgentSessionExecutionLocation): boolean => - Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + this.adapters().some((adapter) => adapter.supportsLocation?.(location) ?? false) - /** Both adapters already gate their own shutdown, so the router only has to stop UNDOING that: + /** Every adapter already gates its own shutdown, so the router only has to stop UNDOING that: * a late acquire must not clear `allAdaptersClosed` and fan a session back out to closed * adapters. Once closed, the router stays closed. */ async acquire(input: Parameters[0]) { if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } - const adapter = this.requireAgent(input.identity) - const acquired = await adapter.acquire(input) + const registration = this.requireAgent(input.identity) + const acquired = await registration.adapter.acquire(input) if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } - this.routes.set(input.identity.sessionId, { adapter, state: 'live' }) + this.routes.set(input.identity.sessionId, { registration, state: 'live' }) return acquired } @@ -48,13 +70,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi const route = this.routes.get(input.sessionId) if (route) { try { - return (await route.adapter.releaseAcquisition?.(input)) === true + return (await route.registration.adapter.releaseAcquisition?.(input)) === true } finally { this.routes.delete(input.sessionId) } } let released = false - for (const candidate of Object.values(this.adapters)) { + for (const candidate of this.adapters()) { released = (await candidate.releaseAcquisition?.(input)) === true || released } return released @@ -63,11 +85,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - rewindSupport: NonNullable = (sessionId, agent) => - this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? { - supported: false, - reason: 'unsupported' + /** The declared capability, narrowed by the adapter's answer for this session. */ + rewindSupport: NonNullable = ( + sessionId, + agent + ) => { + const owner = this.capabilityOwner(sessionId, agent) + if (!owner?.definition.capabilities.rewind) { + return { supported: false, reason: 'unsupported' } } + return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } + } rewind: NonNullable = (input) => this.owner(input.sessionId).rewind?.(input) ?? @@ -96,11 +124,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - supportsThreadGoal = (sessionId: string, agent?: string): boolean => - this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false - - recordsContextUsage = (sessionId: string, agent?: string): boolean => - this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false + capabilities = (sessionId: string, agent?: string): AgentSessionCapabilities | undefined => + this.capabilityOwner(sessionId, agent)?.definition.capabilities stopBackgroundTasks: NonNullable = ( input @@ -132,9 +157,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) atRestCommands: StructuredAgentSessionAtRestCommands = { - read: (record) => this.adapters[record.provider].atRestCommands?.read(record), + read: (record) => this.registrations.get(record.provider)?.adapter.atRestCommands?.read(record), onChange: (listener) => { - const stops = Object.values(this.adapters).flatMap((adapter) => + const stops = this.adapters().flatMap((adapter) => adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : [] ) return () => stops.forEach((stop) => stop()) @@ -164,12 +189,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => - this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + this.requireAgent(input.identity).adapter.historyFilePath?.(input) ?? Promise.resolve(null) providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome - }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) + }) => + this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ?? + Promise.resolve(null) closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) @@ -196,8 +223,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route.state === 'stopped') { return true } - const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId) + const { adapter } = route.registration + const stop = selectStop(adapter) + const stopped = await stop?.call(adapter, sessionId) if (stopped === true) { route.state = 'stopped' return true @@ -243,25 +271,29 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter } - /** The live owner, or for a session at rest the provider it would start under. */ - private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null { - return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null) + /** The live owner, or for a session at rest the agent it would start under. */ + private capabilityOwner(sessionId: string, agent?: string): StructuredAgentRegistration | null { + const route = this.routes.get(sessionId) + if (route?.state === 'live') { + return route.registration + } + return agent ? (this.registrations.get(agent) ?? null) : null } private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { const route = this.routes.get(sessionId) - return route?.state === 'live' ? route.adapter : null + return route?.state === 'live' ? route.registration.adapter : null } - private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { - const adapter = this.adapterForAgent(identity.agent) - if (!adapter) { + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration { + const registration = this.registrations.get(identity.agent) + if (!registration) { throw new Error(`structured sessions do not support ${identity.agent}`) } - return adapter + return registration } - private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { - return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + private adapters(): StructuredAgentSessionAdapter[] { + return [...this.registrations.values()].map((registration) => registration.adapter) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 318bada1fe52..eaa18ca334d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,6 +47,7 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -319,10 +320,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** Whether this session can change its goal; `agent` answers one at rest. */ - supportsThreadGoal?(sessionId: string, agent?: string): boolean - /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ - recordsContextUsage?(sessionId: string, agent?: string): boolean + /** What this session's agent declares it can do; `agent` answers one at rest. Answered by the + * router from the agent's registered definition, never by a single adapter. */ + capabilities?(sessionId: string, agent?: string): AgentSessionCapabilities | undefined /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts index 7e9ffcd2ac2b..b995b1a373fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts @@ -18,6 +18,7 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' let host: StructuredAgentSessionHost let acquire: Mock @@ -89,7 +90,10 @@ describe('a mutation while an import is owed', () => { it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { await attach() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - Object.assign(host.deps.adapter, { changeThreadGoal, supportsThreadGoal: () => true }) + Object.assign(host.deps.adapter, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) const owed = oweImport() providerOpensTurn('turn-g', 901) providerEvents().appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index e17d5ddf074a..929f99845ed0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -12,12 +12,10 @@ import { type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' -import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' -import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' +import { structuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -25,13 +23,11 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick -/** With no catalog for the account, the list a running child falls back to: Claude's built-in - * models. A Codex child has no such list, so none: the client fills the current model from its - * own unknown-model defaults, unchanged from before this list was shared. */ +/** With no catalog for the account, the list a running child of this agent falls back to. */ function restingFallbackModels( provider: AgentSessionRecord['provider'] ): AgentSessionModelOption[] | null { - return provider === 'claude' ? claudeFallbackModelOptions() : null + return structuredAgentDefinition(provider)?.restingOptions.fallbackModels() ?? null } async function readStructuredAgentSessionOptionsAtRest( @@ -59,11 +55,10 @@ async function readStructuredAgentSessionOptionsAtRest( saved.model ?? (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ?? '' - // As a live child answers: the pick, else what Claude runs for this model when none is sent. - // A live Codex child answers only the effort its thread reported, never the model's default. + // As a live child answers: the pick, else the model's default where the agent reports that. const effort = saved.effort ?? - (record.provider === 'claude' + (structuredAgentDefinition(record.provider)?.restingOptions.effortDefaultsToModel ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -87,9 +82,8 @@ export async function recordStructuredAgentSessionOptionIntent( ): Promise> { const record = store.getRecord(ctx.sessionId) const accepted = - record?.provider === 'codex' - ? isCodexTurnOptionKey(input.key) - : record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key) + record !== undefined && + structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) === true if (!record || !accepted) { return { ok: false, @@ -133,6 +127,7 @@ export async function readStructuredAgentSessionOptions( const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase const agent = session.params.provider + const capabilities = adapter.capabilities?.(sessionId, agent) return { ...options, rewind: @@ -142,11 +137,9 @@ export async function readStructuredAgentSessionOptions( supported: false, reason: 'unsupported' }), - conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'], - ...(adapter.supportsThreadGoal?.(sessionId, agent) - ? { threadGoal: { current: session.journal.threadGoal() } } - : {}), - ...(adapter.recordsContextUsage?.(sessionId, agent) + conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], + ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), + ...(capabilities?.contextUsage ? { contextUsage: { current: session.journal.contextUsage() } } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 15af150c12f1..ce07eeb65a72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -12,10 +12,8 @@ export function adapterSupportsCreate( if (adapter.supportsCreate) { return adapter.supportsCreate(location, agent) } - if (agent !== 'codex') { - return false - } - // Older Codex adapters exposed only location support; absence still fails closed here. + // An adapter with no per-agent gate serves the one agent it was built for, so only its location + // support can refuse; absence still fails closed here. return adapter.supportsLocation?.(location) ?? false } @@ -38,6 +36,6 @@ export function adapterSupportsRecord( if (adapter.supportsCreate) { return adapter.supportsCreate(record.location, record.provider) } - // Old Codex records stay readable unless the adapter explicitly rejects their location. - return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true) + // A record stays readable unless the adapter explicitly rejects its location. + return adapter.supportsLocation?.(record.location) ?? true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 6fc7b6c2d52a..97e7e359f6aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' const CALLER = { callerKey: 'client-1' } @@ -65,7 +66,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption, changeThreadGoal, - supportsThreadGoal: () => true, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities, stopBackgroundTasks }, journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 5ab883380d54..5a95d7c90b21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -18,6 +18,8 @@ import { import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -88,7 +90,10 @@ describe('performThreadGoalChange', () => { const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const result = await performThreadGoalChange( - context(journal, { changeThreadGoal, supportsThreadGoal: () => true }), + context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }), { clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } } ) @@ -117,7 +122,7 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const ctx = context(journal, { changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }), - supportsThreadGoal: () => true + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities }) const result = await performThreadGoalChange(ctx, { @@ -142,7 +147,7 @@ describe('performThreadGoalChange', () => { changeThreadGoal: async () => { throw new Error('connection closed') }, - supportsThreadGoal: () => true + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities }) await expect( @@ -164,7 +169,7 @@ describe('performThreadGoalChange', () => { ] const ctx = context(journal, { changeThreadGoal: () => attempts.shift()!(), - supportsThreadGoal: () => true + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities }) const input = { clientOperationId: 'op-9', @@ -192,7 +197,10 @@ describe('performThreadGoalChange', () => { it('journals nothing for a status change or clear', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) await performThreadGoalChange(ctx, { clientOperationId: 'op-3', @@ -209,7 +217,10 @@ describe('performThreadGoalChange', () => { const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const result = await performThreadGoalChange( - context(journal, { changeThreadGoal, supportsThreadGoal: () => false }), + context(journal, { + changeThreadGoal, + capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities + }), { clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } } ) @@ -232,7 +243,10 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() await appendGoalRow(journal, { status: 'complete' }) const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) await performThreadGoalChange(ctx, { clientOperationId: 'op-6', @@ -262,7 +276,10 @@ describe('performThreadGoalChange', () => { it('reads a goal the provider reported, still landing when the set arrives, as the one it replaces', async () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true }) + const ctx = context(journal, { + changeThreadGoal, + capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + }) // Issued, not yet landed: the set's read takes its place behind it in the journal's queue. const landing = appendGoalRow(journal, { status: 'active' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index f1612e03db2c..1ab4e5cfaa6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -47,7 +47,7 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { + if (!ctx.adapter.changeThreadGoal || !ctx.adapter.capabilities?.(ctx.sessionId)?.threadGoal) { return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 2bf67f0b4f88..25a042051609 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -24,6 +24,11 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' +import { + agentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( @@ -125,31 +130,31 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR * Same resolver as the create intent above — never a second copy. */ async resolveStructuredAgentAccountHome( - agent: 'claude' | 'codex' - ): Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }> { + agent: AgentSessionHandleProvider + ): Promise { const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) if (agent === 'claude') { - return { - variable: 'CLAUDE_CONFIG_DIR', - path: resolveStructuredClaudeAccountHomePath({ + return agentSessionAccountHome( + agent, + resolveStructuredClaudeAccountHomePath({ launchEnv, wslDistro: null, getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) }) - } + ) } - return { - variable: 'CODEX_HOME', + return agentSessionAccountHome( + agent, // Read-only resolver, never launch prep: a picker mount or discovery read // must not sync homes, start bridges, or clear an account selection. - path: await resolveStructuredCodexAccountHomePath({ + await resolveStructuredCodexAccountHomePath({ launchEnv, resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn }) - } + ) } protected async resolveStructuredAgentSessionIntent( 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 b846be96dd22..1f70438af2d2 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 @@ -32,6 +32,8 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' +import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' const CLIENT = { clientId: 'device-1', @@ -370,8 +372,10 @@ describe('options at rest', () => { it('answers the provider-level features of a chat at rest (P2-17)', async () => { await restingChat() Object.assign(rig.host.deps.adapter, { - supportsThreadGoal: (_id: string, agent?: string) => agent === 'codex', - recordsContextUsage: (_id: string, agent?: string) => agent === 'claude', + capabilities: (_id: string, agent?: string) => + agent === 'codex' + ? CODEX_STRUCTURED_AGENT.capabilities + : CLAUDE_STRUCTURED_AGENT.capabilities, rewindSupport: (_id: string, agent?: string) => agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..49665fd27c39 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -27,6 +27,8 @@ 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 @@ -301,9 +303,15 @@ async function installOnJournal( ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore }) - const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - }) + const adapter = new StructuredAgentSessionAdapterRouter( + [ + { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } + ], + async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + } + ) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 47fa6d201009..4e29e628d36d 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -11,7 +11,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { fakeClaude } from '../claude/claude-structured-session-test-support' -import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { HOST_TEST_NOW, @@ -125,9 +126,7 @@ beforeEach(async () => { logger: log.logger, store, // Only Claude sessions are attached here; the router supplies the production create gate. - adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => - adapter.closeAll() - ), + adapter: claudeAndCodexRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll()), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, @@ -145,10 +144,7 @@ afterEach(async () => { describe('Claude rewind is unsupported', () => { it('refuses a rewind RPC before writing any rewind record', async () => { - expect(adapter.rewindSupport(HOST_TEST_SESSION)).toEqual({ - supported: false, - reason: 'unsupported' - }) + expect(CLAUDE_STRUCTURED_AGENT.capabilities.rewind).toBe(false) expect(await host.rewind(caller, rewindParams(fence()))).toMatchObject({ ok: false, refusal: { rewindReason: 'unsupported' } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index eca921ed086c..6c2b20b6b1a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -123,7 +123,7 @@ export function NativeChatStructuredSession( const retryDelivery = useCallback((clientMessageId: string) => { retryRef.current(clientMessageId) }, []) - const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + const agentLabel = structuredAgentLabel(props.agent) // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 010cd8a68d5a..697c380b8270 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -9,6 +9,7 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' /** * Upgrades the static seed with the host's stored catalog without waiting on @@ -47,7 +48,7 @@ export function useHostModelCatalogUpgrade(args: { worktree } = args useEffect(() => { - if (!enabled || !optionCatalog || (agent !== 'claude' && agent !== 'codex')) { + if (!enabled || !optionCatalog || !isAgentSessionHandleProvider(agent)) { return } let stale = false diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index e3ee7cd1de05..549d5ca3ff7f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -95,9 +95,8 @@ export function useStructuredAgentSessionOptions(args: { target, optionCatalog, enabled: args.isVisible, - // A resumed conversation may keep its own model, so only a new one runs the listed default — - // and only Codex's listing names the configured model; Claude's settings or env may pick another. - namesDefault: launch?.kind === 'new' && agent === 'codex', + // A resumed conversation may keep its own model, so only a new one runs the listed default. + namesDefault: launch?.kind === 'new' && optionCatalog?.hostListingNamesConfiguredModel === true, ...(launch?.worktree ? { worktree: launch.worktree } : {}), fence, activeOptionRecordRef, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 96ad805560d9..157893a8287d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -182,7 +182,7 @@ export function useStructuredAgentSession(args: { runConversationCommand: (command: AgentSessionConversationCommand) => structuredConversationCommands.sendStructuredConversationCommand({ command, - agentName: structuredAgentLabel(agent === 'codex' ? 'codex' : 'claude'), + agentName: structuredAgentLabel(agent), pending: commandPending, blocked: Boolean( transportState.turnId || diff --git a/src/renderer/src/lib/structured-agent-session-launch-label.ts b/src/renderer/src/lib/structured-agent-session-launch-label.ts index 2c733aebfa53..2ccea1158bb3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-label.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-label.ts @@ -1,6 +1,6 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentType } from '../../../shared/agent-status-types' import { getAgentCatalog } from '@/lib/agent-catalog' -export function structuredAgentLabel(agent: AgentSessionHandleProvider): string { +export function structuredAgentLabel(agent: AgentType): string { return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent } diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index 85d6bf20ed99..0cea968dd430 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,4 +1,7 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from '../../../shared/agent-session-provider-handle' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' @@ -45,7 +48,7 @@ function validRecord(value: unknown): value is StructuredAgentLaunchPersistedRec return ( typeof sessionId === 'string' && sessionId.length > 0 && - (agent === 'claude' || agent === 'codex') && + isAgentSessionHandleProvider(agent) && (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 0dd4e4939e9e..668738503766 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -11,13 +11,17 @@ import type { StructuredAgentLaunchHooks } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } export function openStructuredAgentSessionProvisionalTab(args: { worktreeId: string sessionId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider targetGroupId?: string activate?: boolean }): Tab { @@ -67,7 +71,7 @@ export function beginStructuredAgentSessionProvisionalLaunch(args: { return null } const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - if (!worktreeId || (args.plan.agent !== 'claude' && args.plan.agent !== 'codex')) { + if (!worktreeId || !isAgentSessionHandleProvider(args.plan.agent)) { throw new Error('A provisional structured launch needs its workspace and provider.') } try { diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts new file mode 100644 index 000000000000..047f88f19b30 --- /dev/null +++ b/src/shared/agent-session-account-home.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + agentConfigDirectoryVariable, + agentSessionAccountHome, + isAgentConfigDirectoryVariable +} from './agent-session-account-home' +import { isPersistedAgentSessionRecord } from './agent-session-record' +import { agentSessionRecordFixture } from './agent-session-record.test-fixture' +import { encodeAgentSessionRecord } from './agent-session-record-stored-form' + +describe('agent session account home', () => { + it('pins the variables older builds wrote and read', () => { + expect(agentConfigDirectoryVariable('claude')).toBe('CLAUDE_CONFIG_DIR') + expect(agentConfigDirectoryVariable('codex')).toBe('CODEX_HOME') + expect(JSON.stringify(agentSessionAccountHome('codex', '/home/dev/.codex'))).toBe( + '{"variable":"CODEX_HOME","path":"/home/dev/.codex"}' + ) + }) + + it('admits only a variable some agent declares', () => { + expect(isAgentConfigDirectoryVariable('CLAUDE_CONFIG_DIR')).toBe(true) + expect(isAgentConfigDirectoryVariable('CODEX_HOME')).toBe(true) + expect(isAgentConfigDirectoryVariable('PATH')).toBe(false) + expect(isAgentConfigDirectoryVariable(undefined)).toBe(false) + }) + + it('refuses a stored record whose account home names an undeclared variable', () => { + const record = encodeAgentSessionRecord(agentSessionRecordFixture()) + expect(isPersistedAgentSessionRecord(record)).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } + }) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts new file mode 100644 index 000000000000..2a9be3d1f12a --- /dev/null +++ b/src/shared/agent-session-account-home.ts @@ -0,0 +1,45 @@ +/** + * The account an agent session is pinned to: the agent's config directory, and the environment + * variable that points the agent at it. + * + * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), so a + * record stores it beside the path and a new agent names its own here. Stored values are exactly + * what older builds wrote and read. + */ + +import type { AgentSessionHandleProvider } from './agent-session-provider-handle' + +/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ +export type AgentSessionAccountHome = { + /** Environment variable naming the agent's config directory. */ + variable: string + /** Host-resolved absolute path in the execution host's own path syntax. */ + path: string +} + +const CONFIG_DIRECTORY_VARIABLES: Readonly> = { + claude: 'CLAUDE_CONFIG_DIR', + codex: 'CODEX_HOME' +} + +const KNOWN_CONFIG_DIRECTORY_VARIABLES: ReadonlySet = new Set( + Object.values(CONFIG_DIRECTORY_VARIABLES) +) + +/** The variable an account home of this agent pins. */ +export function agentConfigDirectoryVariable(agent: AgentSessionHandleProvider): string { + return CONFIG_DIRECTORY_VARIABLES[agent] +} + +/** The account home of `agent` at `path`. */ +export function agentSessionAccountHome( + agent: AgentSessionHandleProvider, + path: string +): AgentSessionAccountHome { + return { variable: agentConfigDirectoryVariable(agent), path } +} + +/** Admits only a variable some agent declares: a record's variable becomes a child's environment. */ +export function isAgentConfigDirectoryVariable(value: unknown): value is string { + return typeof value === 'string' && KNOWN_CONFIG_DIRECTORY_VARIABLES.has(value) +} diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts new file mode 100644 index 000000000000..64a8b0b0a322 --- /dev/null +++ b/src/shared/agent-session-capabilities.ts @@ -0,0 +1,39 @@ +/** + * What a structured agent can do, declared before any session runs. + * + * Each agent's definition declares one record, and readers branch on it instead of on the agent's + * name. A running session may narrow a declared capability (a Codex thread with legacy history + * cannot rewind) but never widens one. Plain data, so a host can send it to clients as it is. + */ +export type AgentSessionCapabilities = { + /** Roll the conversation back to before a turn. */ + rewind: boolean + /** Compact the conversation's context on request: offers the `/compact` command. */ + compact: boolean + /** A goal the user can set and change on the thread. */ + threadGoal: boolean + /** Turns report how much of the context window they used. */ + contextUsage: boolean + /** Prompts may carry images. */ + imagePrompts: boolean + /** A message sent while a turn runs: handed to the running agent at once (`inject`), or held by + * Orca and sent as the next prompt once the turn ends (`queue`). */ + steering: 'inject' | 'queue' + /** + * Who applies the user's permission setting. `provider`: the agent is launched with it and + * confines its own work. `orca`: the agent asks and Orca answers by the setting, so only what it + * asks about is covered, never a sandbox. + */ + approvalEnforcement: 'provider' | 'orca' +} + +/** The answer for an agent no definition covers: nothing beyond a plain chat, claimed by no one. */ +export const NO_AGENT_SESSION_CAPABILITIES: AgentSessionCapabilities = Object.freeze({ + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' +}) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed1..8ebd710aeadf 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -213,6 +213,7 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption { export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { supportsWorkerLaunchPreferences: true, + hostListingNamesConfiguredModel: true, // Why: Codex model access depends on auth. Keep this seed short and allow // unknown persisted ids to pass through instead of claiming a complete list. models: [ diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 74e488e5549a..34f7884b6c13 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -77,6 +77,9 @@ export type AgentSessionOptionCatalog = { * model while the picker, which never reads launch args, still names the CLI default. * A real fix means threading `modelApply.agentArgsOverride` through to the surface. */ defaultModelIsCliDefault?: true + /** The host's model listing names the model the account is configured to run, so a new chat + * may run the listed default. Off where the agent's own settings or env may pick another. */ + hostListingNamesConfiguredModel?: true listModels?: { command: string parse: (stdout: string) => CatalogModel[] diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 963d79b14699..5022b8f0c0b5 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -26,6 +26,12 @@ import { type AgentSessionProviderHandleLink } from './agent-session-provider-handle' import { agentSessionProviderHandleBelongsTo } from './agent-session-provider-handle-encoding' +import { + isAgentConfigDirectoryVariable, + type AgentSessionAccountHome +} from './agent-session-account-home' + +export type { AgentSessionAccountHome } from './agent-session-account-home' export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const @@ -44,13 +50,6 @@ export type AgentSessionExecutionLocation = { workspaceKind: AgentSessionWorkspaceKind } -/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ -export type AgentSessionAccountHome = { - variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME' - /** Host-resolved absolute path in the execution host's own path syntax. */ - path: string -} - /** Provider launch environment captured by the host when the session is created. */ export type AgentSessionLaunchEnv = Record @@ -233,8 +232,7 @@ function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccount } const home = value as Partial return ( - (home.variable === 'CLAUDE_CONFIG_DIR' || home.variable === 'CODEX_HOME') && - isBoundedString(home.path, MAX_PATH_LENGTH) + isAgentConfigDirectoryVariable(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) ) } diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 4c25de1a1bdc..16f6f4f45831 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,4 +1,8 @@ import { sha256 } from './sha256' +import { + isAgentSessionHandleProvider, + type AgentSessionHandleProvider +} from './agent-session-provider-handle' function canonicalize(value: unknown): string { if (value === null || typeof value !== 'object') { @@ -41,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider resumeFrom?: { providerSessionId: string } tabId?: string }): string { @@ -66,11 +70,7 @@ export function showStructuredAgentSessionChoice(input: { workspaceSupport: boolean agent: string }): boolean { - return ( - input.hostCapability && - input.workspaceSupport && - (input.agent === 'claude' || input.agent === 'codex') - ) + return input.hostCapability && input.workspaceSupport && isAgentSessionHandleProvider(input.agent) } export function createStructuredAgentSessionOperationId( From 63508a51c342d7d31c0836175620ac9fa88e9f3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:37:36 -0700 Subject: [PATCH 05/45] refactor(native-chat): name the structured agent list once in host types The host types, conversation commands, provider-session ownership and the runtime's create path spelled the structured agent list inline; they now use the one shared type the record and wire already validate against. --- .../structured-agent-session-host-types.ts | 3 ++- .../agent-session-wire/structured-conversation-command.ts | 3 ++- .../structured-provider-session-ownership.ts | 3 ++- ...runtime-get-structured-agent-session-create-support.ts | 8 ++++---- 4 files changed, 10 insertions(+), 7 deletions(-) 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 6552f8a26016..1f456038f8d7 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 @@ -18,6 +18,7 @@ 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 { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -28,7 +29,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider readable: boolean } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 73093da47447..a466d00d2b5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -43,7 +44,7 @@ export type ConversationReplacement = { sourceSessionId: string sessionId: string workspaceId: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider } const clearFingerprintOf = (sessionId: string) => diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index d34bb5ac87fe..9a73ae2d8c0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,9 +1,10 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredProviderSessionOwnership = { sessionId: string workspaceId: string - provider: 'claude' | 'codex' + provider: AgentSessionHandleProvider providerSessionId: string lease: AgentSessionLease } diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 25a042051609..1caa929185b6 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 @@ -33,7 +33,7 @@ import type { AgentSessionHandleProvider } from '../../shared/agent-session-prov export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) return resolveStructuredAgentSessionCreateSupport({ @@ -50,7 +50,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR /** The saved selection a new chat here starts with. createSupport reports it too, so a client's * picker shows what create will run; one resolver keeps the two from drifting. */ structuredAgentSessionLaunchSeedOptions( - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider ): Record | undefined { return resolveStructuredLaunchSeedOptions( this.requireStore().getSettings().nativeChatSessionOptions, @@ -98,7 +98,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider callerKey?: string resumeFrom?: { providerSessionId: string } }): Promise { @@ -161,7 +161,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'claude' | 'codex' + agent: AgentSessionHandleProvider callerKey?: string resumeFrom?: { providerSessionId: string } }, From 88a09c4e4c6c93f3ca070fbb2da519e6f7ff7ee3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:37:25 -0700 Subject: [PATCH 06/45] fix(native-chat): narrow the record before reading its agent's option rules --- .../structured-agent-session-options-read.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 929f99845ed0..235c82f829ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -81,10 +81,10 @@ export async function recordStructuredAgentSessionOptionIntent( input: { key: string; value: string } ): Promise> { const record = store.getRecord(ctx.sessionId) - const accepted = - record !== undefined && - structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) === true - if (!record || !accepted) { + if ( + !record || + !structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) + ) { return { ok: false, refusal: refuse( From 38ac91090884b34e4924815b7c089fc4e1039d9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:14:23 -0700 Subject: [PATCH 07/45] refactor(native-chat): make the router's registrations the only agent definition lookup The at-rest option reads looked definitions up in a second, separately populated map, so a registered definition could route and declare capabilities while the static map decided which options a chat at rest accepted. The router now answers `definition(agent)` from its registrations, the at-rest readers take it through the host's adapter, and the built-in definitions are listed only where the runtime composes the router. --- ...-structured-effort-default-at-rest.test.ts | 7 +- .../structured-agent-definition.ts | 20 +-- ...nt-session-adapter-router-registry.test.ts | 14 +- ...ent-session-adapter-router-test-support.ts | 6 + ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 4 + ...-session-claude-unproven-stop-send.test.ts | 6 +- ...structured-agent-session-host-mutations.ts | 2 +- ...nt-session-operation-start-refusal.test.ts | 10 +- .../structured-agent-session-options-read.ts | 36 ++-- ...gent-session-registered-definition.test.ts | 158 ++++++++++++++++++ .../structured-agent-session-rest-test-rig.ts | 4 +- .../structured-conversation-command.test.ts | 2 + src/shared/agent-session-capabilities.ts | 5 +- 14 files changed, 225 insertions(+), 52 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 0228b1171683..61abbfbba30b 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -14,6 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { @@ -148,7 +149,11 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { const resting = { child: null, params: { provider: 'claude' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: { definition: claudeAndCodexDefinition }, + store: { getRecord: () => record }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index 7578c42463c3..89614e66e6fd 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 @@ -1,13 +1,11 @@ // What the host knows about a structured agent before any session of it runs. // -// Each agent's own module declares its definition; shared host code reads the definition and never -// branches on the agent's name. The router routes sessions to adapters registered with these -// definitions, and at-rest reads with no adapter in hand look one up by agent here. +// Each agent's own module declares its definition, and runtime composition registers it with the +// agent's adapter. The router's registrations are the only lookup: shared host code reads a +// definition through them and never branches on the agent's name. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' export type StructuredAgentDefinition = { /** The Orca agent whose sessions this definition describes. */ @@ -23,15 +21,3 @@ export type StructuredAgentDefinition = { effortDefaultsToModel: boolean } } - -const DEFINITIONS: ReadonlyMap = new Map( - [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].map((definition) => [ - definition.agent, - definition - ]) -) - -/** The definition of an agent this build drives in structured sessions; null for any other. */ -export function structuredAgentDefinition(agent: string): StructuredAgentDefinition | null { - return DEFINITIONS.get(agent) ?? null -} 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 08c77443d7ac..516296a06d5e 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 @@ -10,10 +10,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { - structuredAgentDefinition, - type StructuredAgentDefinition -} from './structured-agent-definition' +import type { StructuredAgentDefinition } from './structured-agent-definition' const LOCAL: AgentSessionExecutionLocation = { executionHostId: 'local', @@ -147,7 +144,7 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { describe('structured agent definitions', () => { it('declares what Claude and Codex structured chats already did', () => { - expect(structuredAgentDefinition('claude')?.capabilities).toEqual({ + expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({ rewind: false, compact: true, threadGoal: false, @@ -156,7 +153,7 @@ describe('structured agent definitions', () => { steering: 'inject', approvalEnforcement: 'provider' }) - expect(structuredAgentDefinition('codex')?.capabilities).toEqual({ + expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({ rewind: true, compact: true, threadGoal: true, @@ -165,12 +162,11 @@ describe('structured agent definitions', () => { steering: 'inject', approvalEnforcement: 'provider' }) - expect(structuredAgentDefinition('grok')).toBeNull() }) it('keeps each agent’s resting option rules with its definition', () => { - const claude = structuredAgentDefinition('claude')!.restingOptions - const codex = structuredAgentDefinition('codex')!.restingOptions + const claude = CLAUDE_STRUCTURED_AGENT.restingOptions + const codex = CODEX_STRUCTURED_AGENT.restingOptions expect(claude.fallbackModels()?.length).toBeGreaterThan(0) expect(codex.fallbackModels()).toBeNull() expect(claude.effortDefaultsToModel).toBe(true) 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 b0e39d2c84e8..ef9e6c9f5a38 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 @@ -2,6 +2,7 @@ import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-de import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import type { StructuredAgentDefinition } from './structured-agent-definition' /** A router with this build's two agents registered under their real definitions. */ export function claudeAndCodexRouter( @@ -16,3 +17,8 @@ export function claudeAndCodexRouter( closeAdapters ) } + +/** The router's definition lookup over the same two agents, for a double that stands in for it. */ +export function claudeAndCodexDefinition(agent: string): StructuredAgentDefinition | null { + return [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].find((d) => d.agent === agent) ?? null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 68720023fe65..491702059c7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -127,6 +127,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi capabilities = (sessionId: string, agent?: string): AgentSessionCapabilities | undefined => this.capabilityOwner(sessionId, agent)?.definition.capabilities + definition = (agent: string): StructuredAgentDefinition | null => + this.registrations.get(agent)?.definition ?? null + stopBackgroundTasks: NonNullable = ( input ) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index eaa18ca334d4..9afd50481d2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -48,6 +48,7 @@ import type { ProviderHistoryWindow } from '../agent-session-journal/journal-sub import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { StructuredAgentDefinition } from './structured-agent-definition' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -323,6 +324,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** What this session's agent declares it can do; `agent` answers one at rest. Answered by the * router from the agent's registered definition, never by a single adapter. */ capabilities?(sessionId: string, agent?: string): AgentSessionCapabilities | undefined + /** The definition registered for `agent`, null for an agent this runtime does not drive. Answered + * by the router from its registrations, never by a single adapter. */ + definition?(agent: string): StructuredAgentDefinition | null /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 65dcfa420738..3c2b77e41b1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -21,6 +21,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -75,7 +76,10 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), + adapter: Object.assign(adapter, { + supportsCreate: () => true, + definition: claudeAndCodexDefinition + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf87..443882c93cb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -156,7 +156,7 @@ export async function setStructuredAgentSessionOption( }, run: (ctx) => atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) + ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) : plan.run(ctx) }, openForProviderWrite(context, params.envelope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 47aecdaa00cc..c3e7909b8cf1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,6 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -44,9 +45,12 @@ describe('an option picked while the chat is at rest', () => { const persistOptions = vi.fn(async () => {}) const refused = await recordStructuredAgentSessionOptionIntent( { - getRecord: () => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. - ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + store: { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + adapter: { definition: claudeAndCodexDefinition } }, { sessionId: SESSION, persistOptions, publish: () => {} }, { key: 'notAnOption', value: 'x' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 235c82f829ee..e3d75139f6c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -7,7 +7,6 @@ import { refuse, - type AgentSessionModelOption, type AgentSessionOptionResult, type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' @@ -15,7 +14,8 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' -import { structuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -23,26 +23,28 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick -/** With no catalog for the account, the list a running child of this agent falls back to. */ -function restingFallbackModels( - provider: AgentSessionRecord['provider'] -): AgentSessionModelOption[] | null { - return structuredAgentDefinition(provider)?.restingOptions.fallbackModels() ?? null +/** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ +function restingOptionRules( + adapter: Pick, + record: AgentSessionRecord +): StructuredAgentDefinition['restingOptions'] | null { + return adapter.definition?.(record.provider)?.restingOptions ?? null } async function readStructuredAgentSessionOptionsAtRest( - deps: Pick, + deps: Pick, sessionId: string ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } + const rules = restingOptionRules(deps.adapter, record) const catalog = (await deps.modelCatalog ?.read({ agent: record.provider, sessionId }) .catch(() => null)) ?? { origin: 'unknown' as const } - const listed = - catalog.origin === 'unknown' ? restingFallbackModels(record.provider) : catalog.models + // With no catalog for the account, the list a running child of this agent falls back to. + const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models const models = listed ?? [] const saved = record.options ?? {} const fastMode = @@ -58,7 +60,7 @@ async function readStructuredAgentSessionOptionsAtRest( // As a live child answers: the pick, else the model's default where the agent reports that. const effort = saved.effort ?? - (structuredAgentDefinition(record.provider)?.restingOptions.effortDefaultsToModel + (rules?.effortDefaultsToModel ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -76,15 +78,15 @@ async function readStructuredAgentSessionOptionsAtRest( /** Records a pick for the next start. Only a key the provider would accept is kept. */ export async function recordStructuredAgentSessionOptionIntent( - store: Pick, + deps: { + store: Pick + adapter: Pick + }, ctx: Pick, input: { key: string; value: string } ): Promise> { - const record = store.getRecord(ctx.sessionId) - if ( - !record || - !structuredAgentDefinition(record.provider)?.restingOptions.acceptsKey(input.key) - ) { + const record = deps.store.getRecord(ctx.sessionId) + if (!record || !restingOptionRules(deps.adapter, record)?.acceptsKey(input.key)) { return { ok: false, refusal: refuse( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts new file mode 100644 index 000000000000..74bacfe930d3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -0,0 +1,158 @@ +// The router's registrations are the only source of an agent's definition: routing, declared +// capabilities and the option rules a chat at rest reads all follow what composition registered, +// and a definition this build ships but did not register decides nothing. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { + readStructuredAgentSessionOptions, + recordStructuredAgentSessionOptionIntent +} from './structured-agent-session-options-read' + +const RECORD: AgentSessionRecord = { + ...agentSessionRecordFixture(), + options: { model: 'pilot-model' } +} + +/** Claude, registered with rules unlike the ones Claude's own module declares. */ +const NON_DEFAULT: StructuredAgentDefinition = { + ...CLAUDE_STRUCTURED_AGENT, + capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true }, + restingOptions: { + acceptsKey: (key) => key === 'pilotOption', + fallbackModels: () => [ + { id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] } + ], + effortDefaultsToModel: true + } +} + +function fakeAdapter(): StructuredAgentSessionAdapter { + return { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: claudeProviderHandle('provider-session-1', null), + origin: 'created' as const, + mintedAtFence: fence, + observedAt: 1 + } + })), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +function pick( + adapter: StructuredAgentSessionAdapter, + record: AgentSessionRecord | null, + key: string +) { + const persistOptions = vi.fn(async () => {}) + const result = recordStructuredAgentSessionOptionIntent( + { store: { getRecord: () => record }, adapter }, + { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, + { key, value: 'enabled' } + ) + return { result, persistOptions } +} + +function readAtRest(adapter: StructuredAgentSessionAdapter) { + const resting = { + child: null, + params: { provider: RECORD.provider }, + journal: { threadGoal: () => null, contextUsage: () => null } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { adapter, store: { getRecord: () => RECORD } }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + return readStructuredAgentSessionOptions(context, RECORD.sessionId) +} + +describe('a registered definition', () => { + it('routes, declares capabilities and rules options at rest for its agent', async () => { + const adapter = fakeAdapter() + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: NON_DEFAULT, adapter }], + async () => {} + ) + + await router.acquire({ + identity: { + sessionId: 'session-routed', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: null + }, + fence: 1, + spawnToken: 'spawn-1' + }) + expect(adapter.acquire).toHaveBeenCalledOnce() + expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBe(NON_DEFAULT.capabilities) + + const accepted = pick(router, RECORD, 'pilotOption') + await expect(accepted.result).resolves.toMatchObject({ ok: true }) + expect(accepted.persistOptions).toHaveBeenCalledWith({ + model: 'pilot-model', + pilotOption: 'enabled' + }) + // Claude's own module accepts `model`; the registration says otherwise and wins. + const refused = pick(router, RECORD, 'model') + await expect(refused.result).resolves.toMatchObject({ ok: false }) + + const options = await readAtRest(router) + expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) + // No pick for effort: the registered rules read the model's default. + expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' }) + expect(options.threadGoal).toEqual({ current: null }) + }) + + it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() }], + async () => {} + ) + + expect(router.definition(RECORD.provider)).toBeNull() + expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBeUndefined() + const refused = pick(router, RECORD, 'model') + await expect(refused.result).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } + }) + expect(refused.persistOptions).not.toHaveBeenCalled() + const options = await readAtRest(router) + expect(options.models).toEqual([]) + expect(options.current).toEqual({ model: 'pilot-model' }) + expect(options.conversationCommands).toEqual(['clear']) + }) + + it('refuses a pick for a session with no record, as before', async () => { + const router = new StructuredAgentSessionAdapterRouter( + [{ definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }], + async () => {} + ) + const missing = pick(router, null, 'model') + await expect(missing.result).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(missing.persistOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index bd21fa3ff339..0cd28fafb63d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -21,6 +21,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -169,7 +170,8 @@ export async function createRestTestRig( releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), - setOption: async () => undefined + setOption: async () => undefined, + definition: claudeAndCodexDefinition }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index b50166cd98d9..828128bc2b71 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -9,6 +9,7 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter @@ -101,6 +102,7 @@ beforeEach(async () => { compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) adapter = { + definition: claudeAndCodexDefinition, supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, acquire: vi.fn(async (input) => { diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts index 64a8b0b0a322..78d63ef70d60 100644 --- a/src/shared/agent-session-capabilities.ts +++ b/src/shared/agent-session-capabilities.ts @@ -21,8 +21,9 @@ export type AgentSessionCapabilities = { steering: 'inject' | 'queue' /** * Who applies the user's permission setting. `provider`: the agent is launched with it and - * confines its own work. `orca`: the agent asks and Orca answers by the setting, so only what it - * asks about is covered, never a sandbox. + * confines its own work. `orca`: Orca answers every permission request the agent sends by the + * session's setting (a protocol-driven agent's value). The agent's own settings still decide + * WHEN it asks, so only what it asks about is covered, never a sandbox. */ approvalEnforcement: 'provider' | 'orca' } From eba54cdc5d431043db301c8b6166cb4cc11f4b26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:57:06 -0700 Subject: [PATCH 08/45] feat(native-chat): open the structured-chat wire and stored records to registered agents A host's structured agents are the ones its runtime registered. Records, RPC params, persisted tabs and the model catalog accept any registered agent instead of naming Claude and Codex; each agent's definition declares the transport its handles live in and the variable its account home pins. A new runtime capability, agent-session.structured.registered-agents.v1, advertises that a host accepts and lists its agents (agentSession.agents, with each agent's capability record), and the host withholds any other agent's tabs and restart offers from clients that do not advertise it. --- .../claude-structured-acquire-catalog.ts | 11 ++ .../claude-structured-agent-definition.ts | 3 + .../claude-structured-launch-resolution.ts | 4 +- .../claude-structured-session-acquisition.ts | 8 +- .../codex/codex-structured-acquire-catalog.ts | 3 +- .../codex-structured-agent-definition.ts | 3 + .../codex-structured-launch-resolution.ts | 4 +- .../agent-model-catalog-fingerprint.ts | 11 +- .../agent-model-catalog-persistence.ts | 3 +- .../agent-model-catalog-service.ts | 11 +- .../agent-model-catalog-store.ts | 6 +- .../agent-project-model-override.ts | 2 +- .../structured-agent-definition.ts | 6 +- ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-attach.ts | 6 +- .../structured-agent-session-host-types.ts | 4 +- .../structured-agent-session-host.ts | 6 + ...ructured-agent-session-send-preparation.ts | 5 +- ...uctured-agent-session-status-child-work.ts | 4 +- .../structured-conversation-command.ts | 4 +- .../structured-provider-session-ownership.ts | 4 +- ...structured-agent-session-create-support.ts | 9 +- .../agent-session-legacy-record-import.ts | 18 +- src/main/runtime/agent-session-record-rows.ts | 15 +- .../agent-session-record-store-file.ts | 21 ++- ...agent-session-record-store-test-harness.ts | 7 +- .../runtime/agent-session-record-store.ts | 9 +- .../agent-session-reservation-admission.ts | 4 +- src/main/runtime/agent-session-store-draft.ts | 6 +- .../runtime/agent-session-store-row-rules.ts | 8 +- .../agent-session-store-transactions.ts | 6 +- ...structured-agent-session-create-support.ts | 170 +++++++++++------- ...tore-structured-agent-session-tabs-once.ts | 8 +- src/main/runtime/rpc/methods/index.ts | 2 + .../session-tab-agent-status-projection.ts | 30 ++-- .../structured-agent-session-agents.ts | 23 +++ .../structured-agent-session-create.ts | 13 +- .../structured-agent-session-policy.ts | 17 ++ ...structured-agent-session-restart-resume.ts | 15 +- .../structured-agent-session-schemas.ts | 1 + .../rpc/methods/structured-agent-session.ts | 6 +- .../structured-agent-session-journal-open.ts | 6 +- .../structured-agent-session-runtime.ts | 15 +- .../structured-session-worktree-teardown.ts | 3 +- .../runtime/structured-worker-authority.ts | 7 +- src/shared/agent-session-account-home.ts | 31 +--- .../agent-session-provider-handle-encoding.ts | 34 ++-- src/shared/agent-session-provider-handle.ts | 13 +- src/shared/agent-session-record.ts | 35 ++-- src/shared/agent-session-registered-agents.ts | 73 ++++++++ ...agent-session-stored-agent.test-fixture.ts | 19 ++ src/shared/agent-session-stored-agent.ts | 50 ++++++ src/shared/protocol-version.ts | 9 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-params.ts | 16 +- .../runtime-mobile-session-tab-contracts.ts | 5 +- ...uctured-agent-session-child-work-legacy.ts | 8 +- .../structured-native-chat-launch-route.ts | 16 +- src/shared/workspace-session-schema.ts | 10 +- 60 files changed, 618 insertions(+), 235 deletions(-) create mode 100644 src/main/claude/claude-structured-acquire-catalog.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-agents.ts create mode 100644 src/shared/agent-session-registered-agents.ts create mode 100644 src/shared/agent-session-stored-agent.test-fixture.ts create mode 100644 src/shared/agent-session-stored-agent.ts 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-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 95e43e2a276b..6fea15d79fb7 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}`) } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e95..74c14f84301e 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..9802b18286df 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}`) } 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.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index a8cc70ade7c9..24bd41df0fcf 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 @@ -20,12 +19,12 @@ export type AgentModelCatalogServiceDeps = { /** The account home a structured launch for this agent would pin right now — * the SAME resolver the create path fills `record.accountHome` with, so a * record-less read can never answer from another account's listing. */ - resolveAccountHome: (agent: 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 +32,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 @@ -58,7 +57,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 { 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 dcb6e3935ac5..bb6055e4e39f 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 @@ -19,7 +19,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 @@ -144,7 +144,7 @@ export class AgentModelCatalogStore { recordSuccess( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, success: AgentModelCatalogSuccess ): AgentModelCatalogEntry | null { if (success.models.length === 0) { @@ -180,7 +180,7 @@ export class AgentModelCatalogStore { * Resolves with the entry on success and null on failure — never rejects. */ refresh( fingerprint: string, - agent: 'claude' | 'codex', + agent: string, listModels: () => Promise ): Promise { const inFlight = this.refreshes.get(fingerprint) 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 89614e66e6fd..8969a13eba29 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 @@ // definition through them and never branches 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-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 491702059c7c..ba83184ec405 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -130,6 +130,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi definition = (agent: string): StructuredAgentDefinition | null => this.registrations.get(agent)?.definition ?? null + definitions = (): readonly StructuredAgentDefinition[] => + [...this.registrations.values()].map((registration) => registration.definition) + stopBackgroundTasks: NonNullable = ( input ) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9afd50481d2e..ad29ef58e651 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -327,6 +327,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The definition registered for `agent`, null for an agent this runtime does not drive. Answered * by the router from its registrations, never by a single adapter. */ definition?(agent: string): StructuredAgentDefinition | null + /** Every registered definition, in registration order: what the host publishes to clients. */ + definitions?(): readonly StructuredAgentDefinition[] /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 415e261ab3ca..f31e48c1eef3 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' 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 1f456038f8d7..09ff44c73035 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 @@ -18,7 +18,7 @@ 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 { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -29,7 +29,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId readable: boolean } 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 5b5a4d19c3c1..69e7d2792af6 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. @@ -215,6 +216,11 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) + /** What this runtime registered: one agent's definition (null when unregistered), or all of them. */ + agentDefinition = (agent: string) => this.deps.adapter.definition?.(agent) ?? null + agentDefinitions = (): readonly StructuredAgentDefinition[] => + this.deps.adapter.definitions?.() ?? [] + private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, this.sessions, 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 77413f456340..71888797d773 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 { @@ -158,7 +159,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 a466d00d2b5e..6bc8f20925f0 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 a6323db075a1..b3bdd441760f 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -2,6 +2,8 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { GlobalSettings } from '../../shared/global-settings-types' import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' +import { isTuiAgent } from '../../shared/tui-agent-config' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import { readClaudeManagedAccountGateSettings, structuredClaudeMatchesActiveManagedAccount, @@ -19,7 +21,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 & @@ -38,7 +40,10 @@ export function resolveStructuredAgentSessionCreateSupport(input: { } // This host's own launch command override names a process only a terminal runs, whichever // client asked; a client routes on its own override for its own machine only. - if (hasExplicitTuiLaunchCommand(readSettingsOrNull(input.getSettings), input.agent)) { + if ( + isTuiAgent(input.agent) && + hasExplicitTuiLaunchCommand(readSettingsOrNull(input.getSettings), input.agent) + ) { return { supported: false, reason: 'agent' } } // Claude only: Codex resolves its account on a different path, so its answer is untouched here. diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index f632f008e764..d92a9254c8de 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -26,6 +26,7 @@ import { type AgentSessionStoreImportRows } from './agent-session-record-rows' import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export type LegacyAgentSessionRecords = | { kind: 'absent' } @@ -52,10 +53,15 @@ function readFailedTransiently(error: unknown): boolean { /** Never throws. */ export async function readLegacyAgentSessionRecords( stateDirectory: string, - hostId: string + hostId: string, + agents: AgentSessionStoredAgents ): Promise { try { - const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) + const loaded = await loadAgentSessionStore( + legacyAgentSessionStorePath(stateDirectory), + hostId, + agents + ) return loaded.storeFound ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } : { kind: 'absent' } @@ -96,7 +102,8 @@ function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): A function importRows( state: AgentSessionStoreState, - fromBackup: boolean + fromBackup: boolean, + agents: AgentSessionStoredAgents ): AgentSessionStoreImportRows { const records = new Map(state.records) if (fromBackup) { @@ -113,7 +120,7 @@ function importRows( // A record the load rules refuse is kept as its bytes, which every load then sets aside. rows.push([ sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json), agents) ? json : JSON.stringify(encodeAgentSessionRecord(loaded)) ]) @@ -137,6 +144,7 @@ function importRows( export function legacyAgentSessionRecordImport( legacy: LegacyAgentSessionRecords, hostId: string, + agents: AgentSessionStoredAgents, report: (report: LegacyAgentSessionRecordImportReport) => void ): JournalLegacyRecordImport { switch (legacy.kind) { @@ -153,7 +161,7 @@ export function legacyAgentSessionRecordImport( } let rows: AgentSessionStoreImportRows try { - rows = importRows(legacy.state, legacy.fromBackup) + rows = importRows(legacy.state, legacy.fromBackup, agents) } catch (error) { report({ kind: 'unusable', error }) return NO_LEGACY_JOURNAL_RECORDS diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 1b1fe6e1b3b2..8f998e2a4a79 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -24,6 +24,7 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' /** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; * no row count can say which. */ @@ -63,8 +64,8 @@ function parseJson(json: string | null): { ok: true; value: unknown } | { ok: fa } } -function unreadableRecordReason(value: unknown): string { - if (isPersistedAgentSessionRecord(value)) { +function unreadableRecordReason(value: unknown, agents: AgentSessionStoredAgents): string { + if (isPersistedAgentSessionRecord(value, agents)) { return 'record_key_session_id_mismatch' } const schemaVersion = @@ -83,7 +84,8 @@ function unreadableRecordReason(value: unknown): string { */ export function loadAgentSessionStoreRows( db: Database.Database, - hostId: string + hostId: string, + agents: AgentSessionStoredAgents ): AgentSessionStoreState { const state: AgentSessionStoreState = { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -111,14 +113,17 @@ export function loadAgentSessionStoreRows( } const parsed = parseJson(text(row, 'record_json')) const value = parsed.ok ? parsed.value : text(row, 'record_json') - if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { + if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value, agents)) { const { record } = decodePersistedAgentSessionRecord(value) state.records.set(sessionId, { ...record, lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } }) } else { - state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), raw: value }) + state.unreadableRecords.set(sessionId, { + reason: unreadableRecordReason(value, agents), + raw: value + }) } } for (const row of db diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 6b5632a87abf..4c37bfa17ce9 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -18,6 +18,7 @@ import { } from '../../shared/agent-session-record' import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -74,7 +75,11 @@ function emptyState(hostId: string): AgentSessionStoreState { } } -function parseState(raw: string, hostId: string): Pick | null { +function parseState( + raw: string, + hostId: string, + agents: AgentSessionStoredAgents +): Pick | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -129,7 +134,7 @@ function parseState(raw: string, hostId: string): Pick { const missing = [...state.unreadableRecords.keys()].filter( (sessionId) => !state.records.has(sessionId) @@ -234,7 +240,7 @@ async function salvageUnreadableRecordsFromBackup( } catch { return } - const backup = parseState(raw, hostId) + const backup = parseState(raw, hostId, agents) if (!backup) { return } @@ -248,7 +254,8 @@ async function salvageUnreadableRecordsFromBackup( export async function loadAgentSessionStore( filePath: string, - hostId: string + hostId: string, + agents: AgentSessionStoredAgents ): Promise { let unusableStoreFound = false for (const [candidate, recoveredFromBackup] of [ @@ -267,13 +274,13 @@ export async function loadAgentSessionStore( } continue } - const parsed = parseState(raw, hostId) + const parsed = parseState(raw, hostId, agents) if (!parsed) { unusableStoreFound = true continue } if (!recoveredFromBackup) { - await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) + await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId, agents) } return { ...parsed, diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index ab879fa159b0..e90aad5497f0 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -12,6 +12,8 @@ import { isAgentSessionProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' import { closeTestJournalHostDatabase, @@ -47,11 +49,12 @@ function databaseFor(stateDirectory: string): Database.Database { /** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ export async function openTestAgentSessionRecordStore( stateDirectory: string, - options: { hostId?: string } = {} + options: { hostId?: string; agents?: AgentSessionStoredAgents } = {} ): Promise { return AgentSessionRecordStore.open({ journalDatabase: openTestJournalHostDatabase(stateDirectory), - hostId: options.hostId ?? TEST_HOST_ID + hostId: options.hostId ?? TEST_HOST_ID, + agents: options.agents ?? CLAUDE_AND_CODEX_STORED_AGENTS }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0fd..f61c27012c8a 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,6 +70,7 @@ import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-sess import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -83,14 +84,16 @@ export class AgentSessionRecordStore { readonly hostId: string ) {} - /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is. */ + /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is; + * `agents` are the agents it registered, the only ones whose records it reads or writes. */ static open(args: { journalDatabase: JournalHostDatabase hostId: string + agents: AgentSessionStoredAgents }): AgentSessionRecordStore { - const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId, args.agents) return new AgentSessionRecordStore( - new AgentSessionStoreTransactions(args.journalDatabase, loaded), + new AgentSessionStoreTransactions(args.journalDatabase, loaded, args.agents), args.hostId ) } 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-draft.ts b/src/main/runtime/agent-session-store-draft.ts index e39027d0042a..616ff60b2ca5 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -15,6 +15,7 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import type { PersistedAgentSessionTab } from './agent-session-tab-table' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' /** Unreadable rows are derived at load and never written, so the draft shares them. */ export function draftAgentSessionStoreState(state: AgentSessionStoreState): AgentSessionStoreState { @@ -85,12 +86,13 @@ function retiredClaimKeysChanged( */ export function agentSessionStoreDraftRowWrites( published: AgentSessionStoreState, - draft: AgentSessionStoreState + draft: AgentSessionStoreState, + agents: AgentSessionStoredAgents ): AgentSessionStoreRowWrites | null { const records = serializeChangedRows( published.records, draft.records, - (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written), + (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written, agents), encodeAgentSessionRecord ) const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index 6d2f4ddf6b2e..6f9c5fccf158 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -12,15 +12,17 @@ import { import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' 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. */ +/** A record row a load keeps in `records` rather than quarantining: one of a registered agent. */ export function isReadableAgentSessionStoreRecord( sessionId: string, - value: unknown + value: unknown, + agents: AgentSessionStoredAgents ): value is PersistedAgentSessionRecord { - return isPersistedAgentSessionRecord(value) && value.sessionId === sessionId + return isPersistedAgentSessionRecord(value, agents) && value.sessionId === sessionId } export function isReadableAgentSessionStoreOperation( diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 879ff4a160e8..d0bcb7197d5f 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -17,6 +17,7 @@ import { draftAgentSessionStoreState, type AgentSessionStoreRowWrites } from './agent-session-store-draft' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' // Why: rows are diffed by identity, so a row changed in place would never be written. Tests and // development builds make that a TypeError; packaged builds skip the walk. @@ -75,7 +76,8 @@ export class AgentSessionStoreTransactions { constructor( private readonly journalDatabase: JournalHostDatabase, - loaded: AgentSessionStoreState + loaded: AgentSessionStoreState, + private readonly agents: AgentSessionStoredAgents ) { freezeRows(loaded, null) this.published = loaded @@ -122,7 +124,7 @@ export class AgentSessionStoreTransactions { const published = this.published const draft = draftAgentSessionStoreState(published) const result = apply(draft) - const writes = agentSessionStoreDraftRowWrites(published, draft) + const writes = agentSessionStoreDraftRowWrites(published, draft, this.agents) return { result, writes, 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 1caa929185b6..d3bf0e191343 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 @@ -28,29 +28,97 @@ 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() }) } + /** Claude and Codex answer from their own location rules without installing the host, as they + * always have; any other agent is the answer of the adapter the host registered for it. */ + protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { + if (agent === 'claude') { + return supportsClaudeStructuredLocation(location) + } + if (agent === 'codex') { + return supportsCodexStructuredLocation(location) + } + await ensureStructuredAgentSessionHostUnlessRefused(() => + this.ensureStructuredAgentSessionHost() + ) + return getStructuredAgentSessionHost()?.supportsCreate(location, agent) ?? false + } + + /** Where a launch of `agent` finds its account, resolved on this host; null for an agent with no + * resolver here, whose create is refused. Claude and Codex are the agents with one today. */ + protected structuredAgentAccountHomePathResolver( + agent: StructuredAgentId, + worktree: string, + purpose: 'launch' | 'read' + ) { + if (agent === 'claude') { + return async ({ launchEnv, location }) => + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) + }) + } + if (agent === 'codex') { + if (purpose === 'read') { + // Read-only resolver, never launch prep: a picker mount or discovery read + // must not sync homes, start bridges, or clear an account selection. + return async ({ launchEnv }) => + resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn + }) + } + return async ({ launchEnv }) => { + await applyStructuredCodexWorkspaceTrust({ + workspacePath: (await this.resolveRuntimeFileTarget(worktree)).worktree.path, + launchEnv, + settings: this.requireStore().getSettings() + }) + return resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: this.prepareCodexStructuredLaunchFn + }) + } + } + return null + } + + /** The definition the installed host registered for `agent`: what its account home pins. */ + protected async requireRegisteredStructuredAgent(agent: StructuredAgentId) { + await this.ensureStructuredAgentSessionHost() + const definition = getStructuredAgentSessionHost()?.agentDefinition(agent) ?? null + 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 +166,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 +189,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 = await 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 +224,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 +235,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 = await this.requireRegisteredStructuredAgent(input.agent) const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, @@ -220,10 +271,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: { - variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: adoption ? adoption.accountHomePath : selectedAccountHomePath - }, + accountHome: agentSessionAccountHome( + definition, + adoption ? adoption.accountHomePath : selectedAccountHomePath + ), ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { @@ -231,14 +282,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..425c07e0502b 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 @@ -74,7 +75,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { - if (session.agent !== 'codex' && session.agent !== 'claude') { + // A tab names an agent only this host registered; the record store reads no other's records. + if (!host?.agentDefinition(session.agent)) { return [] } let sessionId = session.sessionId @@ -111,7 +113,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 +141,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/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..d088a13e44f3 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,6 +1,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' @@ -25,9 +26,15 @@ function clientCanRenderStructuredAgentSessionTab( if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { return false } - return ( - tab.agent === 'codex' || - clientCapabilities.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + if (tab.agent === 'codex') { + return true + } + // 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 clientCapabilities.includes( + tab.agent === 'claude' + ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + : STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ) } @@ -65,15 +72,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/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-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..c6403d609488 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,5 +1,7 @@ +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -21,6 +23,21 @@ export function supportsStructuredAgentSessions( ) } +/** Whether a published row naming `agent` is one this client reads. Every client knows Claude and + * Codex; any other agent reaches only a client that renders the host's registered agents. */ +export function clientReadsStructuredAgent( + context: Pick, + agent: string +): boolean { + return ( + isAgentSessionHandleProvider(agent) || + context.clientKind === undefined || + context.clientCapabilities?.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) === true + ) +} + /** * 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-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index a48f897a005e..86f3e3f59ec6 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 @@ -12,6 +12,13 @@ import { structuredCallerFor } from './structured-agent-session-gate' import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' +import { clientReadsStructuredAgent } from './structured-agent-session-policy' +import type { RpcContext } from '../core' + +/** Offers for agents this client cannot show stay on the host for a client that can. */ +function readableOffers(ctx: RpcContext, offers: readonly T[]): T[] { + return offers.filter((offer) => clientReadsStructuredAgent(ctx, offer.agent)) +} export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ @@ -21,9 +28,9 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) return { - sessions: await host.restartResume.list(), + sessions: readableOffers(ctx, await host.restartResume.list()), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. - failed: await host.restartResume.listFailures() + failed: readableOffers(ctx, await host.restartResume.listFailures()) } } }), @@ -43,8 +50,8 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } return { dismissed, - sessions: await host.restartResume.list(), - failed: await host.restartResume.listFailures() + sessions: readableOffers(ctx, await host.restartResume.list()), + failed: readableOffers(ctx, await host.restartResume.listFailures()) } } }), 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..4ee06cd83542 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -85,8 +85,8 @@ async function resolveClientSuppliedAttach(params: z.infer, const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { ...attachWithoutAgent, - provider: params.provider as 'claude' | 'codex', - agent: params.agent as 'claude' | 'codex' + provider: params.provider, + agent: params.agent } as AgentSessionAttachParams return { host, attachParams } } @@ -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-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 54c27fbfe963..fd9fefa8713a 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -9,6 +9,7 @@ import { type LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' +import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. @@ -34,13 +35,16 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string hostId: string + /** The registered agents: a legacy record of any other agent is imported verbatim, unread. */ + agents: AgentSessionStoredAgents logger: StructuredAgentSessionLogger }): Promise { try { const opened = await JournalHostDatabase.open(args.stateDirectory, async () => legacyAgentSessionRecordImport( - await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), + await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId, args.agents), args.hostId, + args.agents, (report) => reportLegacyRecordImport(args.logger, report) ) ) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 49665fd27c39..604b16a9107b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -29,6 +29,7 @@ import { 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 { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -212,6 +213,13 @@ export async function stopStructuredAgentSessionRuntime(options?: { } } +/** The agents this runtime registers. Their definitions bound what the record store reads and + * writes, and each is routed to its adapter below. */ +const STRUCTURED_AGENT_STORAGE = agentSessionStoredAgents([ + CODEX_STRUCTURED_AGENT, + CLAUDE_STRUCTURED_AGENT +]) + async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { // Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped // field arrives here as `undefined`. Refusing to install is loud; guessing a @@ -227,6 +235,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): 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, + agents: STRUCTURED_AGENT_STORAGE + }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), 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.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 da799fa26798..9cb42628f7dc 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -10,7 +10,6 @@ */ import type { - AgentSessionHandleProvider, AgentSessionProviderHandle, AgentSessionProviderHandleNamespace } from './agent-session-provider-handle' @@ -29,13 +28,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( @@ -45,15 +44,18 @@ 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 transport is + * its registered definition's, which the record store checks on every row it loads or writes; a + * chain never changes transport after its first link. + */ 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 provider data is its resume leaf: the transcript entry a resume continues from. */ @@ -97,6 +99,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 c16c87e1490a..ee94d26b3c8d 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 5022b8f0c0b5..acf6ddc308e3 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -22,14 +22,14 @@ 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 { isAgentSessionProviderHandleInNamespace } from './agent-session-provider-handle-encoding' +import type { AgentSessionAccountHome } from './agent-session-account-home' import { - isAgentConfigDirectoryVariable, - type AgentSessionAccountHome -} from './agent-session-account-home' + isDeclaredAccountHomeVariable, + type AgentSessionStoredAgents +} from './agent-session-stored-agent' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -134,7 +134,8 @@ export type AgentSessionRecord = { schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION sessionId: string location: AgentSessionExecutionLocation - provider: AgentSessionHandleProvider + /** The registered agent this session runs; its definition decides what the record may store. */ + provider: string providerHandleChain: AgentSessionProviderHandleLink[] accountHome: AgentSessionAccountHome /** Provider options the user chose, replayed whenever a new owner starts the session. */ @@ -226,13 +227,17 @@ export function isAgentSessionProcessIdentity( ) } -function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { +function isAgentSessionAccountHome( + value: unknown, + agents: AgentSessionStoredAgents +): 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) + isDeclaredAccountHomeVariable(agents, home.variable) && + isBoundedString(home.path, MAX_PATH_LENGTH) ) } @@ -333,20 +338,23 @@ 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. */ + * handles in their typed form. Decode through `decodePersistedAgentSessionRecord` before use. + * `agents` are the host's registered agents: a record of any other agent is not readable here. */ export function isPersistedAgentSessionRecord( - value: unknown + value: unknown, + agents: AgentSessionStoredAgents ): value is PersistedAgentSessionRecord { if (typeof value !== 'object' || value === null) { return false } const record = value as Partial + const agent = typeof record.provider === 'string' ? agents.get(record.provider) : undefined const fieldsValid = record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && - (record.provider === 'claude' || record.provider === 'codex') && - isAgentSessionAccountHome(record.accountHome) && + agent !== undefined && + isAgentSessionAccountHome(record.accountHome, agents) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && (record.conversationCommand === undefined || @@ -366,9 +374,10 @@ export function isPersistedAgentSessionRecord( // The row holds stored handles; validate the chain they decode to. const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain) const head = chain?.at(-1) + const namespace = { transport: agent.handleTransport, agent: agent.agent } return ( chain !== null && - chain.every((link) => agentSessionProviderHandleBelongsTo(link.handle, validated.provider)) && + chain.every((link) => 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.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-fixture.ts b/src/shared/agent-session-stored-agent.test-fixture.ts new file mode 100644 index 000000000000..de441fff4ad1 --- /dev/null +++ b/src/shared/agent-session-stored-agent.test-fixture.ts @@ -0,0 +1,19 @@ +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + CODEX_STRUCTURED_HANDLE_NAMESPACE +} from './agent-session-provider-handle-encoding' +import { agentSessionStoredAgents } from './agent-session-stored-agent' + +/** What the shipped Claude and Codex definitions declare; a runtime test pins them equal. */ +export const CLAUDE_AND_CODEX_STORED_AGENTS = agentSessionStoredAgents([ + { + agent: 'claude', + handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CLAUDE_CONFIG_DIR' + }, + { + agent: 'codex', + handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, + accountHomeVariable: 'CODEX_HOME' + } +]) diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts new file mode 100644 index 000000000000..b013e2afacb1 --- /dev/null +++ b/src/shared/agent-session-stored-agent.ts @@ -0,0 +1,50 @@ +/** + * What a record of one structured agent may store, as the agent's definition declares it. + * + * A host admits records only of agents it registered: the runtime hands its registered agents to + * the record store, and a row of any other agent is set aside unread, never rewritten. Nothing here + * names an agent; the list is the runtime's. + */ + +import type { AgentSessionProviderTransport } from './agent-session-provider-handle' + +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 +} + +/** The agents a record store admits, keyed by agent. */ +export type AgentSessionStoredAgents = ReadonlyMap + +export function agentSessionStoredAgents( + agents: readonly AgentSessionStoredAgent[] +): AgentSessionStoredAgents { + const byAgent = new Map() + for (const { agent, handleTransport, accountHomeVariable } of agents) { + if (byAgent.has(agent)) { + throw new Error(`structured agent ${agent} is registered twice`) + } + byAgent.set(agent, { agent, handleTransport, accountHomeVariable }) + } + return byAgent +} + +/** Admits only a variable a registered agent declares: a record's variable becomes a child's environment. */ +export function isDeclaredAccountHomeVariable( + agents: AgentSessionStoredAgents, + value: unknown +): value is string { + if (typeof value !== 'string') { + return false + } + for (const agent of agents.values()) { + if (agent.accountHomeVariable === value) { + return true + } + } + return false +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81d..bf93b95f80be 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -211,6 +211,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 = @@ -402,6 +410,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_ACCEPTED_SEND_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-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index b44f72041683..860b9233dba3 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,11 +298,14 @@ 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. */ export const ModelCatalogParams = z.strictObject({ - agent: z.enum(['claude', 'codex']), + agent: StructuredAgent, sessionId: SessionId.optional(), worktree: Identifier('Invalid worktree selector').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.ts b/src/shared/structured-native-chat-launch-route.ts index eac3a87268e3..9982a4edef6a 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' @@ -60,6 +61,9 @@ export type StructuredNativeChatSupportInput = { requiresTuiLaunchCommand?: 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. */ @@ -99,7 +103,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') { @@ -115,7 +120,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.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 From e4a78c8a99f00eea0ba01e39afefbbbee67b1e95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:33:29 -0700 Subject: [PATCH 09/45] test(native-chat): cover registered agents on the wire, in storage and across versions --- ...agent-session-legacy-record-import.test.ts | 13 +- ...session-provider-handle-transition.test.ts | 8 +- ...t-session-record-conversation-name.test.ts | 14 +- ...runtime-structured-session-restore.test.ts | 6 + ...on-tab-registered-agent-projection.test.ts | 110 ++++++ ...ured-agent-session-adoption-replay.test.ts | 2 + ...-agent-session-startup-tab-restore.test.ts | 10 +- ...tructured-agent-stored-definitions.test.ts | 17 + src/shared/agent-session-account-home.test.ts | 60 ++- .../agent-session-record-stored-form.test.ts | 9 +- .../agent-session-registered-agents.test.ts | 75 ++++ src/shared/agent-session-stored-agent.test.ts | 70 ++++ ...uctured-agent-session-agent-params.test.ts | 57 +++ ...tructured-native-chat-launch-route.test.ts | 40 +- src/shared/workspace-session-schema.test.ts | 40 ++ ...oss-version-registered-agents.unit.test.ts | 371 ++++++++++++++++++ .../structured-agent-session-host-fixture.ts | 4 +- ...ructured-agent-session-surface-manifest.ts | 9 +- 18 files changed, 878 insertions(+), 37 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts create mode 100644 src/main/runtime/structured-agent-stored-definitions.test.ts create mode 100644 src/shared/agent-session-registered-agents.test.ts create mode 100644 src/shared/agent-session-stored-agent.test.ts create mode 100644 src/shared/rpc-contract/structured-agent-session-agent-params.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts 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..6eeeba7eda1d 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -25,6 +25,7 @@ import { AgentSessionRecordStore } from './agent-session-record-store' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' const NOW = 1_800_000_000_000 const ALPHA = 'session-alpha' @@ -124,7 +125,8 @@ async function install(): Promise<{ const database = await openStructuredAgentSessionJournalDatabase({ logger: log.logger, stateDirectory: root, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }) opened.push(database) // Each report reaches the log as one entry under its scope, its kind as the outcome. @@ -133,7 +135,11 @@ 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', + agents: CLAUDE_AND_CODEX_STORED_AGENTS + }), reports } } @@ -410,7 +416,8 @@ describe('the tab index from before the table', () => { .run(BETA, JSON.stringify(storedForm(record(BETA)))) await AgentSessionRecordStore.open({ journalDatabase: owed.database, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }).setSessionTabVisibility(BETA, true) owed.database.close() await rm(legacyPath(), { recursive: true }) diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index fa3eea6ecf6d..d438868a1fc1 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -6,6 +6,7 @@ import { import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint @@ -75,7 +76,12 @@ describe('reviseAgentSessionProviderResumePoint', () => { handle: { providerData: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') - expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord(second), + CLAUDE_AND_CODEX_STORED_AGENTS + ) + ).toBe(true) }) it('refuses a stale owner, a released lease, and a head minted by another owner', () => { diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts index f9a3cfb3c91d..058c0b526334 100644 --- a/src/main/runtime/agent-session-record-conversation-name.test.ts +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -6,6 +6,7 @@ import { import { encodePersistedAgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' const NOW = 9_000 @@ -13,10 +14,15 @@ const NOW = 9_000 function storedRecordIsValid( record: Omit & { conversationName?: unknown } ): boolean { - return isPersistedAgentSessionRecord({ - ...record, - providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) - }) + return isPersistedAgentSessionRecord( + { + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain( + record.providerHandleChain + ) + }, + CLAUDE_AND_CODEX_STORED_AGENTS + ) } describe('agent session record conversationName validation', () => { diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 92c39dda2835..b1ff4fc5ece1 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from './orca-runtime' +import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' afterEach(() => setStructuredAgentSessionHost(null)) @@ -76,6 +77,7 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -140,6 +142,7 @@ describe('structured session cold restoration', () => { sessionIds: ['session-survives-rollback'] }), restoreReadableSessions, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -194,6 +197,7 @@ describe('structured session cold restoration', () => { reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), restoreReadableSessions, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -228,6 +232,7 @@ describe('structured session cold restoration', () => { restoreReadableSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -341,6 +346,7 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, + agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', 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 8f7b60de517c..99b17ab90485 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 @@ -7,6 +7,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/ import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { claudeAndCodexDefinition } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' @@ -32,6 +33,7 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, + definition: claudeAndCodexDefinition, acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 70be04a9de72..dcbb53c2bb03 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -39,6 +39,7 @@ import { import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -142,7 +143,8 @@ async function seedProfile( const database = await openStructuredAgentSessionJournalDatabase({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence @@ -182,14 +184,16 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string const database = await openStructuredAgentSessionJournalDatabase({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) await AgentSessionRecordStore.open({ journalDatabase: database, - hostId: 'local' + hostId: 'local', + agents: CLAUDE_AND_CODEX_STORED_AGENTS }).setSessionTabVisibility(record.sessionId, true, tabId) database.close() await rm(legacyAgentSessionStorePath(root), { recursive: true }) 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..a5842fe9c796 --- /dev/null +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -0,0 +1,17 @@ +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_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' + +describe('what the shipped definitions let a record store', () => { + it('matches the storage every older build wrote', () => { + for (const definition of [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) { + const { agent, handleTransport, accountHomeVariable } = definition + expect({ agent, handleTransport, accountHomeVariable }).toEqual( + CLAUDE_AND_CODEX_STORED_AGENTS.get(agent) + ) + } + expect(CLAUDE_STRUCTURED_AGENT.accountHomeVariable).toBe('CLAUDE_CONFIG_DIR') + expect(CODEX_STRUCTURED_AGENT.accountHomeVariable).toBe('CODEX_HOME') + }) +}) diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 047f88f19b30..86e6e3bc1147 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -1,37 +1,59 @@ 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' +import { + agentSessionStoredAgents, + isDeclaredAccountHomeVariable +} from './agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' 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"}' + expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('claude')?.accountHomeVariable).toBe( + 'CLAUDE_CONFIG_DIR' ) + expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')?.accountHomeVariable).toBe('CODEX_HOME') + expect( + JSON.stringify( + agentSessionAccountHome(CLAUDE_AND_CODEX_STORED_AGENTS.get('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('admits only a variable a registered agent declares', () => { + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CLAUDE_CONFIG_DIR')).toBe( + true + ) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CODEX_HOME')).toBe(true) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'PATH')).toBe(false) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, undefined)).toBe(false) + const withGrok = agentSessionStoredAgents([ + ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), + { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } + ]) + expect(isDeclaredAccountHomeVariable(withGrok, 'GROK_HOME')).toBe(true) + expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'GROK_HOME')).toBe(false) }) it('refuses a stored record whose account home names an undeclared variable', () => { const record = encodeAgentSessionRecord(agentSessionRecordFixture()) - expect(isPersistedAgentSessionRecord(record)).toBe(true) + expect(isPersistedAgentSessionRecord(record, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) expect( - isPersistedAgentSessionRecord({ - ...record, - accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } - }) + isPersistedAgentSessionRecord( + { ...record, accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } }, + CLAUDE_AND_CODEX_STORED_AGENTS + ) ).toBe(false) }) + + it('refuses registering one agent twice', () => { + expect(() => + agentSessionStoredAgents([ + ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), + { agent: 'codex', handleTransport: 'acp', accountHomeVariable: 'CODEX_HOME' } + ]) + ).toThrow('registered twice') + }) }) diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 3c4381a2f432..1cabb516ca14 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -12,6 +12,7 @@ import { encodePersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' import { isPersistedAgentSessionRecord } from './agent-session-record' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord @@ -97,8 +98,8 @@ describe('a record row older builds wrote', () => { ['Codex', STORED_CODEX_ROW] ])('reads a %s row and writes it back byte for byte', (_name, row) => { const stored: unknown = JSON.parse(row) - expect(isPersistedAgentSessionRecord(stored)).toBe(true) - if (!isPersistedAgentSessionRecord(stored)) { + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) + if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { return } const { record, normalized } = decodePersistedAgentSessionRecord(stored) @@ -108,7 +109,7 @@ describe('a record row older builds wrote', () => { it('hands shared code only the neutral handle', () => { const stored: unknown = JSON.parse(STORED_CLAUDE_ROW) - if (!isPersistedAgentSessionRecord(stored)) { + if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { throw new Error('fixture row must be readable') } const { record } = decodePersistedAgentSessionRecord(stored) @@ -247,7 +248,7 @@ describe('what this build writes', () => { nativeId: 'acp-thread', providerData: 'resume-token' }) - expect(isPersistedAgentSessionRecord(stored)).toBe(false) + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(false) }) }) 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-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts new file mode 100644 index 000000000000..b86bc1bd6f1e --- /dev/null +++ b/src/shared/agent-session-stored-agent.test.ts @@ -0,0 +1,70 @@ +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' +import { agentSessionStoredAgents } from './agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' + +const GROK = { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } +const WITH_GROK = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) + +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', providerData: '{"cwd":"/w"}' } + })), + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok' } + } +} + +describe('records of registered agents', () => { + it('reads a record of any agent the host registered', () => { + const stored = encodeAgentSessionRecord(grokRecord()) + expect(isPersistedAgentSessionRecord(stored, WITH_GROK)).toBe(true) + if (!isPersistedAgentSessionRecord(stored, WITH_GROK)) { + return + } + expect(decodePersistedAgentSessionRecord(stored).record).toEqual(grokRecord()) + }) + + it('sets aside a record of an agent this host did not register', () => { + expect( + isPersistedAgentSessionRecord( + encodeAgentSessionRecord(grokRecord()), + CLAUDE_AND_CODEX_STORED_AGENTS + ) + ).toBe(false) + }) + + it("refuses handles outside the agent's declared transport", () => { + expect( + isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord('other')), WITH_GROK) + ).toBe(false) + }) + + it('keeps reading Claude and Codex records exactly as before', () => { + const stored = encodeAgentSessionRecord(agentSessionRecordFixture()) + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) + expect(isPersistedAgentSessionRecord(stored, WITH_GROK)).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 }), + CLAUDE_AND_CODEX_STORED_AGENTS + ) + ).toBe(false) + }) +}) 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/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 8f5e0f40c2e9..7c64df9144c4 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/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/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..7ab29c66eea6 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -0,0 +1,371 @@ +// 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 +// reads the new agent's record as one of its own; +// - 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). + +import { beforeAll, describe, expect, it } from 'vitest' +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 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 + isPersistedAgentSessionRecord: (value: 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) + baseline = { + ref, + wire: await loadAgentSessionWireBuild(ref), + 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) + +/** 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' } + } +} + +function unifiedTabsOf(parsed: { ok: boolean; value?: unknown }): Record[] { + const value = parsed.value + if (typeof value !== 'object' || value === null || !('unifiedTabs' in value)) { + return [] + } + const unified = value.unifiedTabs + if (typeof unified !== 'object' || unified === null || !(WORKTREE in unified)) { + return [] + } + const tabs: unknown = Reflect.get(unified, WORKTREE) + return Array.isArray(tabs) ? tabs : [] +} + +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']) + 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("sets aside the new agent's record and reads Claude and Codex records as before", () => { + expect( + baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(newAgentRecord())) + ).toBe(false) + 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(baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(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..0a7d2d9ee38b 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,8 @@ 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]) } } 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 7eb4920deec3..3377f56bd0c5 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 @@ -183,7 +183,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: { @@ -294,6 +300,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': From 6b8c332df65dc37aed1a363ffa5bf239c60882be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:39:11 -0700 Subject: [PATCH 10/45] refactor(native-chat): let the record store decide which agents' tabs exist --- ...ca-runtime-restore-structured-agent-session-tabs-once.ts | 5 +---- .../runtime/orca-runtime-structured-session-restore.test.ts | 6 ------ src/main/runtime/rpc/methods/structured-agent-session.ts | 4 ++-- 3 files changed, 3 insertions(+), 12 deletions(-) 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 425c07e0502b..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 @@ -74,11 +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) => { - // A tab names an agent only this host registered; the record store reads no other's records. - if (!host?.agentDefinition(session.agent)) { - return [] - } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index b1ff4fc5ece1..92c39dda2835 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from './orca-runtime' -import { claudeAndCodexDefinition } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' afterEach(() => setStructuredAgentSessionHost(null)) @@ -77,7 +76,6 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -142,7 +140,6 @@ describe('structured session cold restoration', () => { sessionIds: ['session-survives-rollback'] }), restoreReadableSessions, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -197,7 +194,6 @@ describe('structured session cold restoration', () => { reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), restoreReadableSessions, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [] } as never) @@ -232,7 +228,6 @@ describe('structured session cold restoration', () => { restoreReadableSessions: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -346,7 +341,6 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, - agentDefinition: claudeAndCodexDefinition, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 4ee06cd83542..f829560697d4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -85,8 +85,8 @@ async function resolveClientSuppliedAttach(params: z.infer, const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { ...attachWithoutAgent, - provider: params.provider, - agent: params.agent + provider: params.provider as 'claude' | 'codex', + agent: params.agent as 'claude' | 'codex' } as AgentSessionAttachParams return { host, attachParams } } From 745ace695aab2918bd949342cfa12e1f2e4105e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:42:04 -0700 Subject: [PATCH 11/45] test(native-chat): declare the pilot test agent's storage --- .../structured-agent-session-adapter-router-registry.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 516296a06d5e..356abcd6ccba 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: { ...NO_AGENT_SESSION_CAPABILITIES, steering: 'queue', From c8e42c4ae262157b13a1d902064c74c0e181a7e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:47 -0700 Subject: [PATCH 12/45] test(native-chat): read the old build's saved tabs through a parsed shape --- ...ross-version-registered-agents.unit.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 index 7ab29c66eea6..997ae2ea1469 100644 --- 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 @@ -14,6 +14,7 @@ // stays exercised after a release ships the capability (docs/reference/remote-wire-compatibility.md). 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' @@ -196,17 +197,14 @@ function newAgentRecord(): AgentSessionRecord { } } +/** 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 value = parsed.value - if (typeof value !== 'object' || value === null || !('unifiedTabs' in value)) { - return [] - } - const unified = value.unifiedTabs - if (typeof unified !== 'object' || unified === null || !(WORKTREE in unified)) { - return [] - } - const tabs: unknown = Reflect.get(unified, WORKTREE) - return Array.isArray(tabs) ? tabs : [] + const session = parsedSessionTabs.safeParse(parsed.value) + return session.success ? (session.data.unifiedTabs[WORKTREE] ?? []) : [] } describe('a structured agent beyond Claude and Codex, across versions', () => { From 4cc29b0dd49984cf76b181f961ca9d6ef9285147 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:58:37 -0700 Subject: [PATCH 13/45] fix(native-chat): act on restart offers only for agents the calling client can show A paired client too old to show an agent's chat was listed only the offers it could show, but dismissing or continuing all reached every offer on the host, and a named continuation answered with the host's whole remaining inventory. The client's audience now goes to the host with every restart operation: only offers it sees are reserved, dismissed or returned. Without an audience (this host's own process, or a client that shows every agent) nothing changes. --- ...red-agent-session-restart-audience.test.ts | 79 ++++++++++ ...ed-agent-session-restart-failure-ledger.ts | 18 ++- ...tured-agent-session-restart-resume-host.ts | 66 +++++---- ...ctured-agent-session-restart-resume-set.ts | 12 ++ .../agent-session-recovery-capsule.test.ts | 18 +++ .../runtime/agent-session-recovery-capsule.ts | 12 +- .../structured-agent-session-policy.ts | 19 ++- ...tured-agent-session-restart-resume.test.ts | 138 ++++++++++++++++++ ...structured-agent-session-restart-resume.ts | 30 ++-- 9 files changed, 333 insertions(+), 59 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts 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-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 63722d1737cc..0b217d58303b 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 @@ -23,6 +23,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a 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,10 +59,12 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every durable record goes. With an + * audience, only records of agents it sees go, and an unnamed dismissal is no fence. */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void + beforeClearAll: () => void, + audience?: StructuredAgentSessionRestartAudience ) => Promise } @@ -211,8 +214,17 @@ 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. + const hidden = (marker: AgentSessionResumeMarker) => { + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) + } + 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-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index ef8481e2b1f5..3e30b0baf92c 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 @@ -21,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, @@ -55,20 +57,30 @@ 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 durable record goes. */ - dismiss: (sessionIds?: readonly string[]) => Promise + /** Named sessions forget their offer or failure; unnamed, every durable record goes. 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 } @@ -132,15 +144,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, @@ -158,6 +174,7 @@ 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 @@ -168,7 +185,10 @@ export function createStructuredAgentSessionRestartResume( 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 } @@ -222,20 +242,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, @@ -286,8 +302,8 @@ export function createStructuredAgentSessionRestartResume( let remainingCandidates: StructuredAgentSessionResumeCandidate[] | undefined let remainingFailures: StructuredAgentSessionResumeFailure[] | undefined try { - remainingCandidates = await list() - remainingFailures = await failures.list() + remainingCandidates = await list(audience) + remainingFailures = await listFailures(audience) } catch { deps.logger.warn('refreshing restart offers after an action failed', { scope: 'restart-offer-refresh' @@ -307,10 +323,10 @@ 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: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), + dismiss: (sessionIds, audience) => failures.dismiss(sessionIds, witnesses.clear, 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 3c91a9732b9b..ec2a082fbc90 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/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 80ed0bb726e2..7343ab64e8e1 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -199,21 +199,23 @@ 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 chosen = (marker: AgentSessionResumeMarker) => + (named?.has(marker.sessionId) ?? true) && !keep(marker) 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((record) => chosen(record.marker)) .map((record) => record.marker.sessionId) ) if (dismissed.size > 0) { 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 c6403d609488..8088d2621896 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -23,19 +23,18 @@ export function supportsStructuredAgentSessions( ) } -/** Whether a published row naming `agent` is one this client reads. Every client knows Claude and - * Codex; any other agent reaches only a client that renders the host's registered agents. */ -export function clientReadsStructuredAgent( - context: Pick, - agent: string -): boolean { - return ( - isAgentSessionHandleProvider(agent) || - context.clientKind === undefined || +/** Which agents' rows this client reads, or undefined when it reads every agent the host runs. + * Every client knows Claude and Codex; any other agent reaches only a client that renders the + * host's registered agents. */ +export function structuredAgentsReadBy( + context: Pick +): ((agent: string) => boolean) | undefined { + return context.clientKind === undefined || context.clientCapabilities?.includes( STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ) === true - ) + ? undefined + : isAgentSessionHandleProvider } /** 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..88e32f076751 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -0,0 +1,138 @@ +// 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 { + 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' } + +const OLD_CLIENT = STRUCTURED_CLIENT +const NEW_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_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 })) + 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 86f3e3f59ec6..0155ee04e53d 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,13 +12,7 @@ import { structuredCallerFor } from './structured-agent-session-gate' import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' -import { clientReadsStructuredAgent } from './structured-agent-session-policy' -import type { RpcContext } from '../core' - -/** Offers for agents this client cannot show stay on the host for a client that can. */ -function readableOffers(ctx: RpcContext, offers: readonly T[]): T[] { - return offers.filter((offer) => clientReadsStructuredAgent(ctx, offer.agent)) -} +import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ @@ -27,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) return { - sessions: readableOffers(ctx, 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: readableOffers(ctx, await host.restartResume.listFailures()) + failed: await host.restartResume.listFailures(audience) } } }), @@ -42,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) + 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: readableOffers(ctx, await host.restartResume.list()), - failed: readableOffers(ctx, await host.restartResume.listFailures()) + sessions: await host.restartResume.list(audience), + failed: await host.restartResume.listFailures(audience) } } }), @@ -66,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) ) } }), From 1c6ba3ced1e732afc37b10b21879e698e8899393 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:59:44 -0700 Subject: [PATCH 14/45] test(native-chat): read an agent-registering baseline's storage on its own terms The registered-agents downgrade test assumed its baseline release predates registered agents: it expected the saved-tab parser to erase an unknown agent and called the record reader without the agents list. Once a release with this change becomes the baseline, both break. The expectations now follow what the baseline host advertises, and an agent-registering baseline is handed its own Claude and Codex storage. --- ...oss-version-registered-agents.unit.test.ts | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) 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 index 997ae2ea1469..2a2f670465c4 100644 --- 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 @@ -12,6 +12,8 @@ // // 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' @@ -60,7 +62,10 @@ type Baseline = { ) => RuntimeMobileSessionTabsSnapshot parseWorkspaceSession: (raw: unknown) => { ok: boolean; value?: unknown } isStructuredTab: (tab: unknown) => boolean - isPersistedAgentSessionRecord: (value: unknown) => boolean + /** Builds that register agents also take the agents a record may name. */ + isPersistedAgentSessionRecord: (value: unknown, agents?: unknown) => boolean + /** What the baseline's own record store admits from Claude and Codex, when it takes a list. */ + claudeAndCodexStoredAgents: unknown } let baseline: Baseline @@ -92,17 +97,40 @@ beforeAll(async () => { importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') ]) current = await loadAgentSessionWireBuild(WORKING_TREE) + const wire = await loadAgentSessionWireBuild(ref) + const storedAgents = registersAgents(wire) + ? await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-stored-agent.test-fixture.ts' + ) + : null baseline = { ref, - wire: await loadAgentSessionWireBuild(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') + isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord'), + claudeAndCodexStoredAgents: storedAgents + ? member(storedAgents, 'CLAUDE_AND_CODEX_STORED_AGENTS') + : undefined } }, SUITE_TIMEOUT_MS) +/** Whether a build registers agents beyond Claude and Codex. From then on its saved tabs keep any + * agent's id, and its record store admits exactly the agents its runtime registered. */ +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), + baseline.claudeAndCodexStoredAgents + ) +} + /** 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[] { @@ -245,9 +273,15 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { expect(parsed.ok).toBe(true) const tabs = unifiedTabsOf(parsed) expect(tabs.map((tab) => tab.id)).toEqual(['chat-claude', 'chat-new-agent', 'chat-codex']) - 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]) + 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) @@ -255,9 +289,8 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { }) it("sets aside the new agent's record and reads Claude and Codex records as before", () => { - expect( - baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(newAgentRecord())) - ).toBe(false) + // A build that registers agents is given the two it shipped: one without the new agent. + expect(baselineReadsRecord(newAgentRecord())).toBe(false) const claude = agentSessionRecordFixture() const codex: AgentSessionRecord = { ...claude, @@ -269,7 +302,7 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' } } for (const record of [claude, codex]) { - expect(baseline.isPersistedAgentSessionRecord(encodeAgentSessionRecord(record))).toBe(true) + expect(baselineReadsRecord(record)).toBe(true) } }) }) From 1f6c566a30d091dc87f89512665034aea95c75c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:02:36 -0700 Subject: [PATCH 15/45] refactor(native-chat): derive record-store admission from the runtime's agent registrations Which agents a stored record may name and which agents the router drives came from two lists in the runtime, so a newly registered agent could be routed while its records were set aside. One list of registrations now holds each agent's definition and the factory for its adapter: the store's admitted agents are derived from it before the store opens, and the adapters are built from it once it has. --- .../structured-agent-runtime-registrations.ts | 118 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 105 ++++------------ ...tructured-agent-stored-definitions.test.ts | 11 ++ 3 files changed, 154 insertions(+), 80 deletions(-) create mode 100644 src/main/runtime/structured-agent-runtime-registrations.ts 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..bb183d65ba78 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -0,0 +1,118 @@ +// The structured agents this runtime registers, in one list. Each entry's definition decides which +// agents' records the store admits, and its factory builds the adapter the router drives that agent +// with once the store is open, so storage and routing cannot disagree about which agents exist. +// Adding an agent is one more entry. + +import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +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 { + agentSessionStoredAgents, + type AgentSessionStoredAgents +} from '../../shared/agent-session-stored-agent' +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' + +/** 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 +} + +export type StructuredAgentRuntimeRegistration = { + definition: StructuredAgentDefinition + createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter +} + +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, + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + 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, + 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 + }) +} + +export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = + [ + { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter }, + { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter } + ] + +/** What the record store admits: exactly the registered agents' declared storage. */ +export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) +) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 604b16a9107b..0d97bff5bb2f 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -20,26 +20,16 @@ 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 { agentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { 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 +43,11 @@ 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, + STRUCTURED_AGENT_STORAGE, + 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' @@ -213,13 +207,6 @@ export async function stopStructuredAgentSessionRuntime(options?: { } } -/** The agents this runtime registers. Their definitions bound what the record store reads and - * writes, and each is routed to its adapter below. */ -const STRUCTURED_AGENT_STORAGE = agentSessionStoredAgents([ - CODEX_STRUCTURED_AGENT, - CLAUDE_STRUCTURED_AGENT -]) - async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { // Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped // field arrives here as `undefined`. Refusing to install is loud; guessing a @@ -252,7 +239,6 @@ async function installOnJournal( journalDatabase: JournalHostDatabase ): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) - const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId, @@ -262,69 +248,28 @@ async function installOnJournal( 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, - onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { - 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?.())) } }) - 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), - onChildWorkEvidence: (sessionId, evidence) => - host?.publishChildWorkEvidence(sessionId, evidence), - onDispatchSettledLate, - onSessionIdle: releaseUnansweredDispatches, - ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore - }) - const adapter = new StructuredAgentSessionAdapterRouter( - [ - { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } - ], - async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - } + 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 adapter = new StructuredAgentSessionAdapterRouter(registrations, async () => { + await Promise.all(registrations.map((registration) => registration.adapter.closeAll())) + }) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index a5842fe9c796..c3e7fe0927d1 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -2,6 +2,10 @@ 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_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + STRUCTURED_AGENT_STORAGE +} from './structured-agent-runtime-registrations' describe('what the shipped definitions let a record store', () => { it('matches the storage every older build wrote', () => { @@ -14,4 +18,11 @@ describe('what the shipped definitions let a record store', () => { expect(CLAUDE_STRUCTURED_AGENT.accountHomeVariable).toBe('CLAUDE_CONFIG_DIR') expect(CODEX_STRUCTURED_AGENT.accountHomeVariable).toBe('CODEX_HOME') }) + + it('admits exactly the agents the runtime routes, stored as before', () => { + expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + expect(STRUCTURED_AGENT_STORAGE).toEqual(CLAUDE_AND_CODEX_STORED_AGENTS) + }) }) From 0def03e17c2351ca667ca815689eef3d5305c6e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:17:19 -0700 Subject: [PATCH 16/45] fix(native-chat): hand the exit drain a promise for every registered agent --- src/main/runtime/structured-agent-session-runtime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 0d97bff5bb2f..055530627466 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -250,7 +250,9 @@ async function installOnJournal( logger: deps.logger, // 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?.())) + await Promise.all( + registrations.map(({ adapter }) => adapter.drainObservedExits?.() ?? Promise.resolve()) + ) } }) const context: StructuredAgentAdapterContext = { From dcddf27da46b08ae85158151ae2182444b6d0f83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:25:44 -0700 Subject: [PATCH 17/45] fix(native-chat): let the adoption conflict check read any agent's ownership Ownership rows name any registered agent since the stored records opened to them; the adoption check compares by agent, so it takes the same open id. Only Claude and Codex still adopt. --- .../native-chat/structured-agent-session-history-adoption.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 } From 3717fc48b382d87728a8866f4f1fd1295355fcc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:05:33 -0700 Subject: [PATCH 18/45] test(native-chat): use opaque handle in queued rejection fixture --- .../journal-queued-rejection-batch.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts index d218a356fae5..cd14ce2fe943 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity @@ -23,7 +24,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'claude', - providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } + providerHandle: claudeProviderHandle('native-1', null) } const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { From 01f92061398e06d8b6363e5dd4e8861419d95bde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:39:50 -0700 Subject: [PATCH 19/45] test(native-chat): share one Codex journal identity in the integration suite Main grew the suite to the 800-line limit; the opaque-handle import pushed it over. The two tests built the same identity inline. --- ...ructured-agent-session-integration.test.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index cebfab5bdb0d..5ff7cb3029f7 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -53,6 +53,13 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +const JOURNAL_IDENTITY = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: codexProviderHandle(THREAD) +} // The capability set the desktop renderer advertises. Without the pending-send // one the host holds the reply until the send settles, which is a shim for // clients too old to render a pending bubble — not what this suite models. @@ -403,15 +410,8 @@ afterEach(async () => { describe('a structured codex session over agentSession.*', () => { it('hydrates provider options after activating a legacy-imported journal', async () => { - const identity = { - sessionId: SESSION, - workspaceId: WORKSPACE, - hostId: 'local', - agent: 'codex' as const, - providerHandle: codexProviderHandle(THREAD) - } const journal = await journals.open({ - identity, + identity: JOURNAL_IDENTITY, stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') @@ -824,15 +824,8 @@ describe('a structured codex session over agentSession.*', () => { await stopping expect(waitedForFinalAppend).toBe(true) - const identity = { - sessionId: SESSION, - workspaceId: WORKSPACE, - hostId: 'local', - agent: 'codex' as const, - providerHandle: codexProviderHandle(THREAD) - } const reopened = await journals.open({ - identity, + identity: JOURNAL_IDENTITY, stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') From 4178e5a0385c0c16e8a62534e0067357defb0139 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:30:08 -0700 Subject: [PATCH 20/45] refactor(agent-session): name the handle's adapter state resumeCursor Rename the neutral provider handle's providerData to resumeCursor before any row persists the neutral form: it is an adapter-owned resume position (Claude's transcript leaf), never identity. Claude/Codex stored and wire bytes are unchanged; their typed shapes never carried the field. State the stored-form contract (a handle's field set is closed; later per-link data goes on the chain link, which every build preserves) and pin it with a record round-trip test. Document that transport records the id space the native id was minted in, which can differ from the agent's current transport. --- .../claude-structured-resume-point.test.ts | 2 +- ...laude-structured-turn-resume-point.test.ts | 6 +-- ...session-provider-handle-transition.test.ts | 6 +-- .../agent-session-record-store.test.ts | 2 +- ...ude-structured-session-integration.test.ts | 4 +- .../agent-session-provider-handle-encoding.ts | 37 +++++++++--------- .../agent-session-provider-handle.test.ts | 8 ++-- src/shared/agent-session-provider-handle.ts | 17 ++++++--- .../agent-session-record-stored-form.test.ts | 38 +++++++++++++++---- 9 files changed, 77 insertions(+), 43 deletions(-) diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 0b68903c618d..9465feba5d3d 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -88,7 +88,7 @@ describe('Claude resume point is the last completed turn on every exit path', () spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link.handle).toMatchObject({ providerData: 'a3' }) + expect(acquisition.link.handle).toMatchObject({ resumeCursor: 'a3' }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persisted).toEqual([expect.objectContaining({ leafUuid: 'a3' })]) }) diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index fd6790b2deb8..3ffd74aff14f 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -115,12 +115,12 @@ describe('Claude durable resume point at turn end', () => { const { adapter, store, turn } = await liveOwner() try { await turn('u1', 'a1') - expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'a1' }) + expect(store.record.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'a1' }) await turn('u2', 'a2') expect(store.record.providerHandleChain).toHaveLength(2) expect(store.record.providerHandleChain.at(-1)).toMatchObject({ linkId: 'published-link', - handle: { providerData: 'a2' } + handle: { resumeCursor: 'a2' } }) } finally { await adapter.closeAll() @@ -135,7 +135,7 @@ describe('Claude durable resume point at turn end', () => { frame({ type: 'user', uuid: 'u3' }) await tick() const head = store.record.providerHandleChain.at(-1)!.handle - expect(head).toMatchObject({ providerData: 'a2' }) + expect(head).toMatchObject({ resumeCursor: 'a2' }) const resolve = createClaudeStructuredLaunchResolver({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only getRecord from its store. diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index fa3eea6ecf6d..e58827d2fd54 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -34,7 +34,7 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(record.lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'leaf-2' }) expect(next.lease.provenHandleLinkId).toBe('link-2') }) @@ -51,7 +51,7 @@ describe('recordAgentSessionProviderHandle', () => { link: resumedLink(lease.runtimeFence), now: 4_000 }) - expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ providerData: 'leaf-2' }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'leaf-2' }) expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) }) }) @@ -72,7 +72,7 @@ describe('reviseAgentSessionProviderResumePoint', () => { expect(second.providerHandleChain.at(-1)).toMatchObject({ linkId: 'link-1', origin: 'created', - handle: { providerData: 'leaf-3' } + handle: { resumeCursor: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index b03317f144d0..cc0a83cc55f2 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -753,7 +753,7 @@ describe('claim keys and unreadable rows', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-thread', - providerData: 'resume-token' + resumeCursor: 'resume-token' }) }) const reopened = await open() diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index ce95893c69cd..bbafd5f1aae4 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -775,7 +775,7 @@ describe('a structured Claude session over agentSession.*', () => { deps: { store: { getRecord: (sessionId: string) => { - providerHandleChain: { handle: { transport: string; providerData?: string } }[] + providerHandleChain: { handle: { transport: string; resumeCursor?: string } }[] } } } @@ -783,7 +783,7 @@ describe('a structured Claude session over agentSession.*', () => { // A completed turn advances the durable resume point in place while the owner is live. expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)?.handle).toMatchObject({ transport: 'claude-sdk', - providerData: 'assistant-leaf' + resumeCursor: 'assistant-leaf' }) // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next // open resumes the conversation. diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index da799fa26798..c0c2f6c6f57f 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -7,6 +7,9 @@ * in the neutral shape, which an older build refuses as unreadable rather than mistaking for one * of its own. Decoding is strict: each handle has exactly one stored form, and the key and root * strings derived from it are persisted elsewhere, so none of them may ever change. + * + * A stored handle has a closed field set, and a rewrite drops anything else. A later build adds new + * per-link data on the chain link, which every build preserves, never on the handle. */ import type { @@ -56,7 +59,7 @@ export function agentSessionProviderHandleBelongsTo( ) } -/** Claude's provider data is its resume leaf: the transcript entry a resume continues from. */ +/** Claude's resume cursor is its leaf: the transcript entry a resume continues from. */ export function claudeProviderHandle( sessionId: string, leafUuid: string | null @@ -64,12 +67,12 @@ export function claudeProviderHandle( return { ...CLAUDE_STRUCTURED_HANDLE_NAMESPACE, nativeId: sessionId, - ...(leafUuid === null ? {} : { providerData: leafUuid }) + ...(leafUuid === null ? {} : { resumeCursor: leafUuid }) } } export function claudeProviderHandleLeafUuid(handle: AgentSessionProviderHandle): string | null { - return handle.providerData ?? null + return handle.resumeCursor ?? null } export function codexProviderHandle(threadId: string): AgentSessionProviderHandle { @@ -81,7 +84,7 @@ export function codexProviderHandle(threadId: string): AgentSessionProviderHandl const MAX_HANDLE_FIELD_LENGTH = 512 /** A generic handle's key carries escaped ids, so it may outgrow a legacy key's bound. */ const MAX_NEUTRAL_HANDLE_KEY_LENGTH = 8192 -const MAX_PROVIDER_DATA_LENGTH = 4096 +const MAX_RESUME_CURSOR_LENGTH = 4096 const NAMESPACE_PART_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/ export function isAgentSessionProviderHandleField(value: unknown): value is string { @@ -106,7 +109,7 @@ function isLegacyNamespace(handle: AgentSessionProviderHandleNamespace): boolean /** * An in-memory handle. A Claude or Codex handle must also fit its typed stored shape: Claude's - * provider data is a leaf id, and Codex has none. + * resume cursor is a leaf id, and Codex has none. */ export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { if (typeof value !== 'object' || value === null || Object.hasOwn(value, 'provider')) { @@ -115,7 +118,7 @@ export function isAgentSessionProviderHandle(value: unknown): value is AgentSess const transport = 'transport' in value ? value.transport : undefined const agent = 'agent' in value ? value.agent : undefined const nativeId = 'nativeId' in value ? value.nativeId : undefined - const providerData = 'providerData' in value ? value.providerData : undefined + const resumeCursor = 'resumeCursor' in value ? value.resumeCursor : undefined if ( !isNamespacePart(transport) || !isNamespacePart(agent) || @@ -125,16 +128,16 @@ export function isAgentSessionProviderHandle(value: unknown): value is AgentSess } const namespace = { transport, agent } if (isAgentSessionProviderHandleInNamespace(namespace, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { - return providerData === undefined + return resumeCursor === undefined } if (isAgentSessionProviderHandleInNamespace(namespace, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { - return providerData === undefined || isAgentSessionProviderHandleField(providerData) + return resumeCursor === undefined || isAgentSessionProviderHandleField(resumeCursor) } return ( - providerData === undefined || - (typeof providerData === 'string' && - providerData.length > 0 && - providerData.length <= MAX_PROVIDER_DATA_LENGTH) + resumeCursor === undefined || + (typeof resumeCursor === 'string' && + resumeCursor.length > 0 && + resumeCursor.length <= MAX_RESUME_CURSOR_LENGTH) ) } @@ -163,7 +166,7 @@ export function encodePersistedAgentSessionProviderHandle( handle: AgentSessionProviderHandle ): PersistedAgentSessionProviderHandle { if (isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { - return { provider: 'claude', sessionId: handle.nativeId, leafUuid: handle.providerData ?? null } + return { provider: 'claude', sessionId: handle.nativeId, leafUuid: handle.resumeCursor ?? null } } if (isAgentSessionProviderHandleInNamespace(handle, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { return { provider: 'codex', threadId: handle.nativeId } @@ -172,12 +175,12 @@ export function encodePersistedAgentSessionProviderHandle( transport: handle.transport, agent: handle.agent, nativeId: handle.nativeId, - ...(handle.providerData === undefined ? {} : { providerData: handle.providerData }) + ...(handle.resumeCursor === undefined ? {} : { resumeCursor: handle.resumeCursor }) } } /** The neutral form's discriminator and identity fields; a typed stored handle carries none of them. */ -const NEUTRAL_HANDLE_FIELDS = ['transport', 'agent', 'nativeId', 'providerData'] as const +const NEUTRAL_HANDLE_FIELDS = ['transport', 'agent', 'nativeId', 'resumeCursor'] as const export function decodePersistedAgentSessionProviderHandle( value: unknown @@ -215,7 +218,7 @@ export function decodePersistedAgentSessionProviderHandle( transport: value.transport, agent: value.agent, nativeId: value.nativeId, - ...(value.providerData === undefined ? {} : { providerData: value.providerData }) + ...(value.resumeCursor === undefined ? {} : { resumeCursor: value.resumeCursor }) } } @@ -229,7 +232,7 @@ export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle ? `claude:${JSON.stringify([stored.sessionId, stored.leafUuid])}` : `codex:${JSON.stringify(stored.threadId)}` } - // Why: provider data is resume state the adapter owns, not identity; only Claude's leaf, a + // Why: the resume cursor is resume state the adapter owns, not identity; only Claude's leaf, a // branch cursor, was ever part of a key, and its key above is fixed by records already written. return agentSessionProviderHandleRoot(handle) } diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 463eff97ade7..d66c03568151 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -55,7 +55,7 @@ describe('handle identity', () => { it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => { expect(isAgentSessionProviderHandle(claudeProviderHandle('sess-1', null))).toBe(true) - expect(isAgentSessionProviderHandle({ ...CLAUDE, providerData: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, resumeCursor: '' })).toBe(false) expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: '' })).toBe(false) expect(isAgentSessionProviderHandle({ ...CLAUDE, nativeId: ' sess-1 ' })).toBe(false) // A stored typed shape is not an in-memory handle: it must be decoded first. @@ -460,11 +460,11 @@ describe('superseding a creation the provider never saved', () => { }) describe('a transport shared code has never heard of', () => { - const acp = (nativeId: string, providerData?: string): AgentSessionProviderHandle => ({ + const acp = (nativeId: string, resumeCursor?: string): AgentSessionProviderHandle => ({ transport: 'acp', agent: 'grok', nativeId, - ...(providerData === undefined ? {} : { providerData }) + ...(resumeCursor === undefined ? {} : { resumeCursor }) }) it('chains resumes and forks by its native id alone', () => { @@ -495,7 +495,7 @@ describe('a transport shared code has never heard of', () => { expect(forked.at(-1)?.handle.nativeId).toBe('s-2') }) - it('records a same-fence resume that only moved provider data instead of eliding it', () => { + it('records a same-fence resume that only moved the resume cursor instead of eliding it', () => { const created = link({ handle: acp('s-1', 'a') }) const moved = link({ linkId: 'link-2', origin: 'resumed', handle: acp('s-1', 'b') }) expect(appendAgentSessionProviderHandleLink([created], moved)).toHaveLength(2) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index c16c87e1490a..3bf8fb69b06c 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -3,7 +3,7 @@ * * A handle names one provider conversation without saying what the provider means by it. Shared * code reads only its transport, its agent, and the provider's own conversation id; anything else - * the provider needs to resume is `providerData`, which only that provider's adapter reads. How a + * the provider needs to resume is `resumeCursor`, which only that provider's adapter reads. How a * handle is stored and sent lives in agent-session-provider-handle-encoding.ts. * * Resumes extend the chain, forks start a new identity root, and the chain records which is which @@ -44,13 +44,20 @@ export function isAgentSessionHandleProvider(value: unknown): value is AgentSess export type AgentSessionProviderTransport = 'claude-sdk' | 'codex-app-server' | (string & {}) export type AgentSessionProviderHandle = { + /** + * The id space `nativeId` was minted in, which can differ from the agent's current transport. A + * mismatch with the running build makes the chat not resumable there, never the record unreadable. + */ transport: AgentSessionProviderTransport /** Orca agent whose binary resumes the conversation; one transport serves many agents. */ agent: AgentType /** The provider's own conversation id: Claude's session id, Codex's thread id. */ nativeId: string - /** Resume state only the provider's adapter reads, such as Claude's branch leaf. */ - providerData?: string + /** + * Adapter-owned resume position (Claude: transcript leaf). Shared code never parses it; it is never + * identity, except inside Claude's legacy handle key. + */ + resumeCursor?: string } /** Which provider a handle belongs to. Records and leases compare this, never the handle's data. */ @@ -82,7 +89,7 @@ export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ -/** Every field, provider data included: a resume that only moved the adapter's state is still news. */ +/** Every field, resume cursor included: a resume that only moved the adapter's state is still news. */ export function agentSessionProviderHandlesEqual( left: AgentSessionProviderHandle, right: AgentSessionProviderHandle @@ -91,7 +98,7 @@ export function agentSessionProviderHandlesEqual( left.transport === right.transport && left.agent === right.agent && left.nativeId === right.nativeId && - left.providerData === right.providerData + left.resumeCursor === right.resumeCursor ) } diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 3c4381a2f432..22ef2b54a9ad 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -113,7 +113,7 @@ describe('a record row older builds wrote', () => { } const { record } = decodePersistedAgentSessionRecord(stored) expect(record.providerHandleChain.map((link) => link.handle)).toEqual([ - { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', providerData: 'leaf-1' }, + { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-1', resumeCursor: 'leaf-1' }, { transport: 'claude-sdk', agent: 'claude', nativeId: 'sess-2' } ]) }) @@ -170,7 +170,7 @@ describe('what this build writes', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-session-1', - providerData: '{"cwd":"/repo"}' + resumeCursor: '{"cwd":"/repo"}' } const stored = encodePersistedAgentSessionProviderHandle(grok) expect(stored).toEqual(grok) @@ -181,7 +181,7 @@ describe('what this build writes', () => { expect(olderBuildReadsHandle(stored)).toBe(false) expect(isAgentSessionHandleProvider(grok.agent)).toBe(false) expect(agentSessionWireProviderHandle(grok)).toBeNull() - // Root, not provider data: provider data is resume state, not identity. + // Root, not the resume cursor: the cursor is resume state, not identity. expect(agentSessionProviderHandleRoot(grok)).toBe('acp/grok:"acp-session-1"') expect(agentSessionProviderHandleKey(grok)).toBe(agentSessionProviderHandleRoot(grok)) }) @@ -199,7 +199,7 @@ describe('what this build writes', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-thread', - providerData: 'resume-token' + resumeCursor: 'resume-token' }, { provider: 'codex', threadId: 'thread-1', nativeId: 'other-thread' }, { @@ -210,12 +210,12 @@ describe('what this build writes', () => { agent: 'grok', nativeId: 'other' }, - { provider: 'claude', sessionId: 'sess-1', leafUuid: null, providerData: 'leaf-2' }, + { provider: 'claude', sessionId: 'sess-1', leafUuid: null, resumeCursor: 'leaf-2' }, { provider: 'claude', sessionId: 'sess-1' }, { provider: 'claude', sessionId: 'sess-1', leafUuid: '' }, { provider: 'codex', threadId: ' padded ' }, { transport: 'a:b', agent: 'grok', nativeId: 'x' }, - { transport: 'acp', agent: 'grok', nativeId: 'x', providerData: '' }, + { transport: 'acp', agent: 'grok', nativeId: 'x', resumeCursor: '' }, null ]) { expect(decodePersistedAgentSessionProviderHandle(value)).toBeNull() @@ -236,6 +236,30 @@ describe('what this build writes', () => { ).toEqual(claudeProviderHandle('sess-1', 'leaf-1')) }) + it('drops a field a later build put on a handle, but keeps one on the link or record', () => { + const stored = JSON.parse(STORED_CODEX_ROW) + stored.providerHandleChain[0].handle.laterHandleField = 'x' + stored.providerHandleChain[0].laterLinkField = 'y' + stored.laterRecordField = 'z' + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { + return + } + const written = JSON.parse( + JSON.stringify(encodeAgentSessionRecord(decodePersistedAgentSessionRecord(stored).record)) + ) + expect(written.laterRecordField).toBe('z') + expect(written.providerHandleChain[0].laterLinkField).toBe('y') + expect(written.providerHandleChain[0].handle).toEqual({ + provider: 'codex', + threadId: 'thread-new' + }) + + const neutral = { transport: 'acp', agent: 'grok', nativeId: 's', resumeCursor: 'c' } + const decoded = decodePersistedAgentSessionProviderHandle({ ...neutral, laterHandleField: 1 }) + expect(decoded && encodePersistedAgentSessionProviderHandle(decoded)).toEqual(neutral) + }) + it.each([ ['Claude', STORED_CLAUDE_ROW], ['Codex', STORED_CODEX_ROW] @@ -245,7 +269,7 @@ describe('what this build writes', () => { transport: 'acp', agent: 'grok', nativeId: 'acp-thread', - providerData: 'resume-token' + resumeCursor: 'resume-token' }) expect(isPersistedAgentSessionRecord(stored)).toBe(false) }) From 19c8cb5f417486d92147dbd7c8db99001a6eeff7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:50:46 -0700 Subject: [PATCH 21/45] refactor(agent-session): one required agent registry; declarations admit what they claim A StructuredAgentRegistry, built once from the {definition, adapter} registrations, is now a required host dependency and the router routes with it. The adapter interface loses its optional router-only capabilities?() and definition?(); every reader (options at rest, thread goal, rewind, the /compact handover) asks the registry. A live session still narrows rewind through the adapter, and the host combines declared and narrowed in one helper. The registry refuses a registration that declares compact, a thread goal or rewind without the adapter method behind it. /compact is admitted by the declared capability, so an agent that declares compact:false gets the commandRefused fact instead of a thrown error. Also: the cut-turn notice names the agent from the catalog, create-support builds the account home through agentSessionAccountHome, and the turn status text older clients read names the session's agent instead of defaulting to Codex (Claude/Codex text unchanged). --- .../claude-api-retry-idle-sweep.test.ts | 2 + ...claude-context-usage-unloaded-turn.test.ts | 29 +++--- ...-structured-effort-default-at-rest.test.ts | 11 ++- .../claude-structured-session-test-support.ts | 10 +++ .../codex-provider-retry-idle-sweep.test.ts | 2 + .../codex-structured-question-order.test.ts | 2 + .../structured-agent-registry.ts | 86 ++++++++++++++++++ ...-agent-session-accept-then-deliver.test.ts | 2 + ...nt-session-adapter-router-registry.test.ts | 90 ++++++++++++------- ...ent-session-adapter-router-test-support.ts | 62 ++++++++++--- ...structured-agent-session-adapter-router.ts | 49 ++++------ .../structured-agent-session-adapter.ts | 11 +-- ...tured-agent-session-adopted-import.test.ts | 2 + ...ured-agent-session-append-delivery.test.ts | 2 + ...red-agent-session-at-rest-commands.test.ts | 2 + ...-agent-session-claude-compact-stop.test.ts | 2 + ...-agent-session-claude-echo-working.test.ts | 2 + ...-agent-session-claude-option-queue.test.ts | 2 + ...d-agent-session-claude-queued-stop.test.ts | 2 + ...red-agent-session-claude-root-exit.test.ts | 2 + ...t-session-claude-stop-ends-session.test.ts | 12 +-- ...agent-session-claude-stop-turn-end.test.ts | 2 + ...-session-claude-unproven-stop-send.test.ts | 8 +- ...ctured-agent-session-close-verdict.test.ts | 2 + ...tured-agent-session-codex-stop-row.test.ts | 2 + .../structured-agent-session-command-turn.ts | 8 +- ...ed-agent-session-conversation-stop.test.ts | 2 + ...ured-agent-session-crash-mid-start.test.ts | 2 + ...tured-agent-session-crash-turn-end.test.ts | 2 + .../structured-agent-session-delivery-loop.ts | 3 + ...ession-failed-create-owner-verdict.test.ts | 2 + ...session-failed-create-sink-release.test.ts | 2 + ...tured-agent-session-grouped-prompt.test.ts | 2 + .../structured-agent-session-host-delivery.ts | 1 + ...ed-agent-session-host-test-abandon.test.ts | 2 + ...uctured-agent-session-host-test-harness.ts | 2 + .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.test.ts | 3 + ...ured-agent-session-import-mismatch.test.ts | 2 + ...ured-agent-session-late-settlement.test.ts | 2 + ...gent-session-legacy-handoff-record.test.ts | 2 + ...ssion-main-agent-working-agreement.test.ts | 2 + ...ctured-agent-session-mutation-admission.ts | 36 ++------ ...ructured-agent-session-mutation-context.ts | 1 + ...red-agent-session-mutation-turn-context.ts | 38 ++++++++ ...agent-session-operation-settlement.test.ts | 3 + ...nt-session-operation-start-refusal.test.ts | 4 +- ...ed-agent-session-option-settlement.test.ts | 2 + ...uctured-agent-session-options-read.test.ts | 8 +- .../structured-agent-session-options-read.ts | 26 +++--- ...ed-agent-session-owed-work-release.test.ts | 2 + ...ctured-agent-session-prompt-cancel.test.ts | 3 + ...gent-session-provider-child-record.test.ts | 2 + ...red-agent-session-provider-restore.test.ts | 2 + ...red-agent-session-provider-started.test.ts | 2 + ...session-queued-message-rig.test-fixture.ts | 8 +- ...tured-agent-session-recovery-exits.test.ts | 2 + ...ctured-agent-session-refusal-retry.test.ts | 2 + ...gent-session-registered-definition.test.ts | 68 +++++++------- ...uctured-agent-session-repeat-press.test.ts | 47 +++++----- ...ctured-agent-session-repeated-stop.test.ts | 2 + .../structured-agent-session-rest-test-rig.ts | 6 +- ...ssion-restart-interruption-test-harness.ts | 2 + ...session-restart-status-publication.test.ts | 2 + ...tured-agent-session-rewind-at-rest.test.ts | 2 + .../structured-agent-session-rewind.test.ts | 2 + .../structured-agent-session-rewind.ts | 13 ++- ...red-agent-session-send-idempotency.test.ts | 5 ++ ...agent-session-send-open-stale-turn.test.ts | 2 + ...red-agent-session-send-preparation.test.ts | 2 + ...session-send-restarts-failed-start.test.ts | 2 + ...agent-session-settled-attach-retry.test.ts | 7 ++ ...agent-session-start-failure-writer.test.ts | 2 + ...-session-startup-reconcile-failure.test.ts | 2 + ...red-agent-session-surface-lifetime.test.ts | 2 + ...ructured-agent-session-thread-goal.test.ts | 45 ++++------ .../structured-agent-session-thread-goal.ts | 2 +- .../structured-agent-session-turns.test.ts | 15 ++++ .../structured-agent-session-turns.ts | 5 ++ ...sion-view-start-after-failed-start.test.ts | 2 + ...t-session-wedged-profile-migration.test.ts | 2 + .../structured-conversation-command.test.ts | 4 +- .../structured-conversation-command.ts | 1 + ...structured-conversation-compaction.test.ts | 33 ++++++- ...structured-agent-session-create-support.ts | 8 +- ...ured-agent-session-adoption-replay.test.ts | 2 + .../structured-agent-session-at-rest.test.ts | 9 +- .../structured-agent-session-hold.test.ts | 2 + .../methods/structured-chat-tab-table.test.ts | 2 + .../structured-agent-session-runtime.ts | 18 ++-- .../structured-claude-pending-rewind.test.ts | 9 +- ...red-agent-session.cut-turn-notice.test.tsx | 15 ++++ .../use-structured-agent-session.ts | 3 +- src/shared/agent-session-turn-record.test.ts | 16 ++++ src/shared/agent-session-turn-record.ts | 9 +- src/shared/agent-turn-lifecycle-text.ts | 2 +- src/shared/tui-agent-display-names.ts | 5 ++ ...ss-version-agent-session-wire.unit.test.ts | 3 + ...-chat-owner-status-activation.unit.test.ts | 2 + 99 files changed, 669 insertions(+), 294 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-registry.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 695acefbb6f8..c5a05cfb5569 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -20,6 +20,7 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -79,6 +80,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index d98d1e68371e..c1ce921caca2 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -16,7 +16,6 @@ import { import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' @@ -29,7 +28,11 @@ import { import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' +import { + claudeAndCodexAgents, + NO_STRUCTURED_AGENTS +} from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' const SESSION = 'orca-session' const journals = createTrackedJournalOpener() @@ -82,10 +85,7 @@ function runTurn( }) } -function readOptions( - journal: AgentSessionJournal, - adapter: Partial -) { +function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegistry) { const running = { journal, child: { fence: 1, generation: 'generation-1' }, @@ -95,9 +95,9 @@ function readOptions( const context = { deps: { adapter: { - readOptions: async () => ({ models: [], current: { model: 'opus' } }), - ...adapter + readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, + agents, store: { getRecord: () => undefined } }, serialize: (_sessionId: string, task: () => Promise) => task(), @@ -172,9 +172,7 @@ describe('context usage for a turn row outside the loaded page', () => { const state = attachTail(journal, 3) expect(state.hasOlder).toBe(true) expect(hasTurnRow(state)).toBe(false) - const options = await readOptions(journal, { - capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities - }) + const options = await readOptions(journal, claudeAndCodexAgents()) expect(options.contextUsage?.current).toEqual( latestStructuredAgentContextFacts(journal.snapshot().items) @@ -193,7 +191,7 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-a', 1_000, [120_000, 130_000, 140_000]) await settle() - const options = await readOptions(journal, {}) + const options = await readOptions(journal, NO_STRUCTURED_AGENTS) expect(options).not.toHaveProperty('contextUsage') // An older host answers the same way, so the ring reads only the loaded page, as before. expect(selectStructuredAgentContextUsage(attachTail(journal, 2).items)).toBeNull() @@ -216,9 +214,7 @@ describe('context usage for a turn row outside the loaded page', () => { expect(hasTurnRow(live)).toBe(false) expect(live.unloadedTurnRevisions).toBe(1) - const options = await readOptions(journal, { - capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities - }) + const options = await readOptions(journal, claudeAndCodexAgents()) expect( selectStructuredAgentContextUsage(live.items, options.contextUsage?.current) ).toMatchObject({ usedTokens: 250_000, windowTokens: 1_000_000 }) @@ -232,8 +228,7 @@ describe('context usage for a turn row outside the loaded page', () => { runTurn(translator, 'turn-b', 3_000, []) await settle() const hostAnswer = async () => - (await readOptions(journal, { capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities })) - .contextUsage?.current + (await readOptions(journal, claudeAndCodexAgents())).contextUsage?.current // The client reads the host once per turn, and again whenever its window loses a turn row. let state = attachTail(journal, 200) let host = await hostAnswer() diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 61abbfbba30b..edb7c2d0919a 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -14,7 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' -import { claudeAndCodexDefinition } 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' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { @@ -146,11 +146,16 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { getRecord: () => record, resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) }) - const resting = { child: null, params: { provider: 'claude' } } + const resting = { + child: null, + params: { provider: 'claude' }, + journal: { threadGoal: () => null, contextUsage: () => null } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { deps: { - adapter: { definition: claudeAndCodexDefinition }, + adapter: {}, + agents: claudeAndCodexAgents(), store: { getRecord: () => record }, modelCatalog }, diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 02cf46aadc83..2b5d28c99bcb 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -325,3 +325,13 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { export function tick(): Promise { return new Promise((resolve) => setImmediate(resolve)) } + +/** Delivers one frame from Claude on `connection`, under the provider session it runs. */ +export function claudeFrame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +/** Whether anything sent to Claude on `connection` carries `text`. */ +export function claudeWasSent(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 722858828f25..d0263b0c100e 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -20,6 +20,7 @@ import { createCodexJournalTranslator } from './codex-structured-journal-transla import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -77,6 +78,7 @@ beforeEach(async () => { setOption: async () => undefined } host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index c63d78439aba..f942b5f83a88 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -38,6 +38,7 @@ import { import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -95,6 +96,7 @@ beforeEach(async () => { } store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts new file mode 100644 index 000000000000..21e29c008ab7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts @@ -0,0 +1,86 @@ +// The structured agents this runtime drives, built once from their registrations. +// +// The one place a definition is looked up: the router routes with it and every host reader asks it +// what an agent declares. A declaration the agent's adapter could not honour is refused here, so a +// declared capability always has the adapter method behind it. + +import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' +import type { AgentSessionRewindSupport } from '../../../shared/agent-session-rewind' +import type { StructuredAgentDefinition } from './structured-agent-definition' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ +export type StructuredAgentRegistration = { + definition: StructuredAgentDefinition + adapter: StructuredAgentSessionAdapter +} + +type AdapterMethod = keyof StructuredAgentSessionAdapter + +/** The adapter methods each declared capability needs. */ +function requiredAdapterMethods(capabilities: AgentSessionCapabilities): AdapterMethod[] { + return [ + ...(capabilities.compact ? (['compact'] as const) : []), + ...(capabilities.threadGoal ? (['changeThreadGoal'] as const) : []), + ...(capabilities.rewind ? (['rewind', 'recoverRewind'] as const) : []) + ] +} + +export class StructuredAgentRegistry { + private readonly byAgent: ReadonlyMap + + constructor(registrations: readonly StructuredAgentRegistration[]) { + const byAgent = new Map() + for (const registration of registrations) { + const { agent, capabilities } = registration.definition + if (byAgent.has(agent)) { + throw new Error(`structured agent ${agent} is registered twice`) + } + const missing = requiredAdapterMethods(capabilities).find( + (method) => !registration.adapter[method] + ) + if (missing) { + throw new Error( + `structured agent ${agent} declares a capability its adapter has no ${missing} for` + ) + } + byAgent.set(agent, registration) + } + this.byAgent = byAgent + } + + /** The registration for `agent`; null for an agent this runtime does not drive. */ + registration(agent: string): StructuredAgentRegistration | null { + return this.byAgent.get(agent) ?? null + } + + registrations(): readonly StructuredAgentRegistration[] { + return [...this.byAgent.values()] + } + + definition(agent: string): StructuredAgentDefinition | null { + return this.byAgent.get(agent)?.definition ?? null + } + + definitions(): readonly StructuredAgentDefinition[] { + return this.registrations().map((registration) => registration.definition) + } + + /** What `agent` declares; a live session may narrow it (see `rewindSupport`), never widen it. */ + capabilities(agent: string): AgentSessionCapabilities | null { + return this.definition(agent)?.capabilities ?? null + } +} + +/** Whether this session can rewind now: its agent's declaration, narrowed by the adapter. */ +export function structuredAgentRewindSupport( + agents: StructuredAgentRegistry, + adapter: Pick, + sessionId: string, + agent: string +): AgentSessionRewindSupport { + if (!agents.capabilities(agent)?.rewind) { + return { supported: false, reason: 'unsupported' } + } + return adapter.rewindSupport?.(sessionId, agent) ?? { supported: true } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 7cc6e05a6a9b..8272dc897746 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -78,6 +79,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa async function startHost(): Promise { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index 516296a06d5e..a0564157810b 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 @@ -10,6 +10,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentRegistry, structuredAgentRewindSupport } from './structured-agent-registry' import type { StructuredAgentDefinition } from './structured-agent-definition' const LOCAL: AgentSessionExecutionLocation = { @@ -71,11 +72,33 @@ const PILOT: StructuredAgentDefinition = { } } +/** A double with every method Claude's and Codex's declarations need. */ +function declaringAdapter( + overrides: Partial = {} +): StructuredAgentSessionAdapter { + return fakeAdapter({ + compact: vi.fn(), + changeThreadGoal: vi.fn(), + rewind: vi.fn(), + recoverRewind: vi.fn(), + ...overrides + }) +} + +function claudeAndCodex( + adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {} +): StructuredAgentRegistry { + return new StructuredAgentRegistry([ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() }, + { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() } + ]) +} + describe('StructuredAgentSessionAdapterRouter registry', () => { it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { const pilot = fakeAdapter() const router = new StructuredAgentSessionAdapterRouter( - [{ definition: PILOT, adapter: pilot }], + new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]), async () => {} ) @@ -91,51 +114,54 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { it('refuses two registrations for one agent', () => { expect( () => - new StructuredAgentSessionAdapterRouter( - [ - { definition: PILOT, adapter: fakeAdapter() }, - { definition: PILOT, adapter: fakeAdapter() } - ], - async () => {} - ) + new StructuredAgentRegistry([ + { definition: PILOT, adapter: fakeAdapter() }, + { definition: PILOT, adapter: fakeAdapter() } + ]) ).toThrow('structured agent grok is registered twice') }) - it('answers capabilities from the live owner, else from the agent named at rest', async () => { - const router = new StructuredAgentSessionAdapterRouter( - [ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }, - { definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() } - ], - async () => {} + it.each([ + ['compact', 'compact'], + ['threadGoal', 'changeThreadGoal'], + ['rewind', 'rewind'], + ['rewind', 'recoverRewind'] + ] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => { + const declared: StructuredAgentDefinition = { + ...PILOT, + capabilities: { ...PILOT.capabilities, [capability]: true } + } + const adapter = declaringAdapter({ [method]: undefined }) + + expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow( + `structured agent grok declares a capability its adapter has no ${method} for` ) + expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow() + }) - expect(router.capabilities('session-1')).toBeUndefined() - expect(router.capabilities('session-1', 'codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) - expect(router.capabilities('session-1', 'grok')).toBeUndefined() - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) - // A live session answers for its own agent, whatever a caller names. - expect(router.capabilities('session-1', 'codex')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) + it('answers what each registered agent declares, and nothing for an unregistered one', () => { + const agents = claudeAndCodex() + + expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) + expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) + expect(agents.capabilities('grok')).toBeNull() + expect(agents.definition('grok')).toBeNull() + expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) }) it('lets a session narrow a declared rewind but never widen an undeclared one', () => { - const narrowing = fakeAdapter({ + const narrowing = declaringAdapter({ rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) }) - const widening = fakeAdapter({ rewindSupport: () => ({ supported: true }) }) - const router = new StructuredAgentSessionAdapterRouter( - [ - { definition: CODEX_STRUCTURED_AGENT, adapter: narrowing }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: widening } - ], - async () => {} - ) + const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) + const agents = claudeAndCodex({ codex: narrowing, claude: widening }) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - expect(router.rewindSupport('session-1', 'codex')).toEqual({ + expect(structuredAgentRewindSupport(agents, router, 'session-1', 'codex')).toEqual({ supported: false, reason: 'history-not-paginated' }) - expect(router.rewindSupport('session-1', 'claude')).toEqual({ + expect(structuredAgentRewindSupport(agents, router, 'session-1', 'claude')).toEqual({ supported: false, reason: 'unsupported' }) 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 ef9e6c9f5a38..4d1dffb8a391 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 @@ -3,22 +3,62 @@ import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-defin import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' import type { StructuredAgentDefinition } from './structured-agent-definition' +import { StructuredAgentRegistry } from './structured-agent-registry' -/** A router with this build's two agents registered under their real definitions. */ +/** A runtime that drives no agent: what a host over a bare adapter double declares. */ +export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([]) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the optional methods a declaration needs; this double has none, so its agents declare none. +const NO_METHODS = {} as StructuredAgentSessionAdapter + +/** This build's two agents over doubles. Each declares only what its double implements, so a + * double need not carry every method its real adapter has. */ +export function claudeAndCodexAgents( + adapters: + | { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter } + | StructuredAgentSessionAdapter = NO_METHODS +): StructuredAgentRegistry { + const { claude, codex } = 'claude' in adapters ? adapters : { claude: adapters, codex: adapters } + return new StructuredAgentRegistry([ + { definition: implementedBy(CLAUDE_STRUCTURED_AGENT, claude), adapter: claude }, + { definition: implementedBy(CODEX_STRUCTURED_AGENT, codex), adapter: codex } + ]) +} + +/** This build's two agents exactly as declared, for a host whose double gains its methods per test. */ +export function claudeAndCodexDeclared(): StructuredAgentRegistry { + const unused = (): never => { + throw new Error('a registry never calls its adapters') + } + return claudeAndCodexAgents({ + ...NO_METHODS, + compact: unused, + changeThreadGoal: unused, + rewind: unused, + recoverRewind: unused + }) +} + +/** A router over this build's two agents. */ export function claudeAndCodexRouter( adapters: { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter }, closeAdapters: () => Promise ): StructuredAgentSessionAdapterRouter { - return new StructuredAgentSessionAdapterRouter( - [ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude }, - { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex } - ], - closeAdapters - ) + return new StructuredAgentSessionAdapterRouter(claudeAndCodexAgents(adapters), closeAdapters) } -/** The router's definition lookup over the same two agents, for a double that stands in for it. */ -export function claudeAndCodexDefinition(agent: string): StructuredAgentDefinition | null { - return [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT].find((d) => d.agent === agent) ?? null +function implementedBy( + definition: StructuredAgentDefinition, + adapter: StructuredAgentSessionAdapter +): StructuredAgentDefinition { + const declared = definition.capabilities + return { + ...definition, + capabilities: { + ...declared, + compact: declared.compact && Boolean(adapter.compact), + threadGoal: declared.threadGoal && Boolean(adapter.changeThreadGoal), + rewind: declared.rewind && Boolean(adapter.rewind && adapter.recoverRewind) + } + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 491702059c7c..b7191d50f54c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -6,44 +6,30 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' - -/** One structured agent this runtime drives: its definition, and the adapter that runs it. */ -export type StructuredAgentRegistration = { - definition: StructuredAgentDefinition - adapter: StructuredAgentSessionAdapter -} +import type { + StructuredAgentRegistration, + StructuredAgentRegistry +} from './structured-agent-registry' type SessionRoute = { registration: StructuredAgentRegistration state: 'live' | 'stopped' } -/** Routes each session to the adapter registered for its agent. Adding an agent is one more +/** Routes each session to the adapter its agent is registered with. Adding an agent is one more * registration; nothing here names one. */ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { private readonly routes = new Map() - private readonly registrations: ReadonlyMap private allAdaptersClosed = false private closePromise: Promise | null = null constructor( - registrations: readonly StructuredAgentRegistration[], + private readonly agents: StructuredAgentRegistry, private readonly closeAdapters: () => Promise - ) { - const byAgent = new Map() - for (const registration of registrations) { - if (byAgent.has(registration.definition.agent)) { - throw new Error(`structured agent ${registration.definition.agent} is registered twice`) - } - byAgent.set(registration.definition.agent, registration) - } - this.registrations = byAgent - } + ) {} supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { - const adapter = this.registrations.get(agent)?.adapter + const adapter = this.agents.registration(agent)?.adapter return adapter ? (adapter.supportsLocation?.(location) ?? false) : false } @@ -85,13 +71,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - /** The declared capability, narrowed by the adapter's answer for this session. */ + /** The owning adapter's narrowing for this session; the host combines it with the declaration. */ rewindSupport: NonNullable = ( sessionId, agent ) => { const owner = this.capabilityOwner(sessionId, agent) - if (!owner?.definition.capabilities.rewind) { + if (!owner) { return { supported: false, reason: 'unsupported' } } return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } @@ -124,12 +110,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - capabilities = (sessionId: string, agent?: string): AgentSessionCapabilities | undefined => - this.capabilityOwner(sessionId, agent)?.definition.capabilities - - definition = (agent: string): StructuredAgentDefinition | null => - this.registrations.get(agent)?.definition ?? null - stopBackgroundTasks: NonNullable = ( input ) => { @@ -160,7 +140,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) atRestCommands: StructuredAgentSessionAtRestCommands = { - read: (record) => this.registrations.get(record.provider)?.adapter.atRestCommands?.read(record), + read: (record) => + this.agents.registration(record.provider)?.adapter.atRestCommands?.read(record), onChange: (listener) => { const stops = this.adapters().flatMap((adapter) => adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : [] @@ -280,7 +261,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route?.state === 'live') { return route.registration } - return agent ? (this.registrations.get(agent) ?? null) : null + return agent ? this.agents.registration(agent) : null } private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { @@ -289,7 +270,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration { - const registration = this.registrations.get(identity.agent) + const registration = this.agents.registration(identity.agent) if (!registration) { throw new Error(`structured sessions do not support ${identity.agent}`) } @@ -297,6 +278,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } private adapters(): StructuredAgentSessionAdapter[] { - return [...this.registrations.values()].map((registration) => registration.adapter) + return this.agents.registrations().map((registration) => registration.adapter) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9afd50481d2e..5bd42f2b2eb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,8 +47,6 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' -import type { StructuredAgentDefinition } from './structured-agent-definition' export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -267,7 +265,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise }): Promise - /** `agent` answers for a session with no child running, from the provider alone. */ + /** How this session narrows its agent's declared rewind; `agent` answers for one with no child + * running. The host reads the declaration first: this never widens it. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string @@ -321,12 +320,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** What this session's agent declares it can do; `agent` answers one at rest. Answered by the - * router from the agent's registered definition, never by a single adapter. */ - capabilities?(sessionId: string, agent?: string): AgentSessionCapabilities | undefined - /** The definition registered for `agent`, null for an agent this runtime does not drive. Answered - * by the router from its registrations, never by a single adapter. */ - definition?(agent: string): StructuredAgentDefinition | null /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 4f532865452a..621e19d6f949 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -21,6 +21,7 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -236,6 +237,7 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'valid source') store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 5c718da0f3be..b80135108056 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -130,6 +131,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 4b52c9446dec..9106bafa107a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -29,6 +29,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -85,6 +86,7 @@ function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { async function openHost(catalog = catalogFor(workspace)): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: adapter(catalog), journalDatabase: openTestJournalHostDatabase(directory), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index bbdc2b50b2fd..cfb7dfd54834 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -29,6 +29,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -71,6 +72,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index ac1111ac629e..de44f4f8a2ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -67,6 +68,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 868247eb37ce..88e495b5dbdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -18,6 +18,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -85,6 +86,7 @@ beforeEach(async () => { optionWritable = Promise.resolve() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 5e4ac75b0662..744eba82c505 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -83,6 +84,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f09fe672b5ec..1e6ebdb5dab3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -18,6 +18,7 @@ import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agen import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const NOW = 1_788_727_031_330 const roots: string[] = [] @@ -120,6 +121,7 @@ describe('Claude root-exit stop', () => { const deps = { store, adapter, + agents: NO_STRUCTURED_AGENTS, // The one database the store and the journal share, as the runtime installs them. journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 198c2418cf93..449b2645d601 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -22,6 +22,8 @@ import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state' import { + claudeFrame as frame, + claudeWasSent as wrote, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -42,6 +44,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -104,6 +107,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), @@ -181,10 +185,6 @@ async function dispatch(clientMessageId: string) { return { state: submission?.dispatchState, reason: submission?.reason } } -function frame(connection: FakeConnection, message: Record): void { - connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) -} - /** Sends a message and lets Claude open its turn and write one reply; returns the turn's id. */ async function openTurn(connection: FakeConnection, text = 'Write a long reply.'): Promise { const clientMessageId = await send(text) @@ -246,10 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -function wrote(connection: FakeConnection, text: string): boolean { - return connection.sent.some((message) => JSON.stringify(message).includes(text)) -} - const INTERRUPTED_RESULT = { type: 'result', subtype: 'error_during_execution', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 9e10a753f07e..6107c402b889 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -30,6 +30,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -70,6 +71,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 3c2b77e41b1c..a48bb84e0e13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -21,7 +21,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -75,11 +75,9 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), store, - adapter: Object.assign(adapter, { - supportsCreate: () => true, - definition: claudeAndCodexDefinition - }), + adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index b1922b913ed1..740725917bda 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -29,6 +29,7 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } @@ -49,6 +50,7 @@ beforeEach(() => { exitObservedFirst = false closeCalls = 0 host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: state.store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7f828a484688..056d3421ecd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -70,6 +71,7 @@ beforeEach(async () => { }) disposeSession = vi.spyOn(adapter, 'disposeSession') host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index b1654ff70790..e51b8e672f96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -42,6 +42,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { conversationCommandBlocked } from './structured-conversation-command-admission' @@ -153,6 +154,7 @@ export type StructuredAgentSessionCommandHandoverContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext @@ -310,13 +312,17 @@ function commandBlocked( ctx: StructuredAgentSessionCommandHandoverContext, body: AgentJournalMessageItem ): SubmissionRejectionFact | null { - if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND) { return agentSessionFailureFact('commandRefused') } const record = ctx.record() if (!record) { return agentSessionFailureFact('hostFault') } + // The declaration admits it, as it does the advertised command list; a client may send it anyway. + if (!ctx.agents.capabilities(record.provider)?.compact) { + return agentSessionFailureFact('commandRefused') + } const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') return refusal ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 7421fcef307c..bf699a417cdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -63,6 +64,7 @@ beforeEach(async () => { log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 059567386a6d..e10d215dc80a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -93,6 +94,7 @@ function host( overrides: Partial = {} ): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index a8e0f4be94f9..ac3e264ee0cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -49,6 +49,7 @@ import { import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -159,6 +160,7 @@ async function seedClaudeToolTurn(): Promise { function openHost(overrides: Partial): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 83ab0e8e6feb..50819eaab629 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -22,6 +22,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentRegistry } from './structured-agent-registry' import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause @@ -52,6 +53,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise @@ -261,6 +263,7 @@ export class StructuredAgentSessionDeliveryLoop { journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, + agents: this.deps.agents, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 31e7bb718591..a469af94120e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -25,6 +25,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -53,6 +54,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index d6590d6c61a7..e510916b0973 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -23,6 +23,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -49,6 +50,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index a4a12bfce7ec..f308b7903983 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -26,6 +26,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -135,6 +136,7 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 696236805ac8..aa42efab4c9e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -61,6 +61,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const loop = new StructuredAgentSessionDeliveryLoop({ sessions, adapter: deps.adapter, + agents: deps.agents, serialize: input.serialize, trackStart: input.trackStart, ensureProviderChild: input.ensureProviderChild, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index c444f52c031e..f8f9745f8905 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -21,6 +21,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -68,6 +69,7 @@ describe('abandoning a structured agent-session host', () => { setOption: async () => undefined } const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 96fc601134b4..ab748f866b5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -31,6 +31,7 @@ import { } from './structured-agent-session-host-test-data' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const journals = createTrackedJournalOpener() @@ -157,6 +158,7 @@ beforeEach(async () => { recoveryCapsule = new TrackedTestRecoveryCapsule(root) log = recordingProductionStructuredAgentSessionLogger() host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: log.logger, store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 1f456038f8d7..b5bc74ab99ad 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 @@ -18,6 +18,7 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' export type StructuredAgentSessionCaller = { callerKey: string } @@ -103,6 +104,8 @@ export type StructuredAgentSessionHostSession = { export type StructuredAgentSessionHostDeps = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + /** The agents this runtime drives; what each declares is read here, never from the adapter. */ + agents: StructuredAgentRegistry /** Optional advisory recovery storage, independent of conversation backups. */ recoveryCapsule?: AgentSessionRecoveryCapsule /** The host's one chat journal database. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 16251f3cd401..22791df28fa8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -30,6 +30,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' let root: string let store: AgentSessionRecordStore @@ -134,6 +135,7 @@ describe('attach', () => { } })) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, @@ -544,6 +546,7 @@ describe('restart', () => { ) { store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), ...adapterOverrides }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index 5a384ce2bb9e..a8f45664a1d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -26,6 +26,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) @@ -94,6 +95,7 @@ async function relaunchWithMismatchedCopy(): Promise }) const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 81b7200c304e..268b71d152aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -97,6 +98,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 00b9f617891d..918afba6c5b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -26,6 +26,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -44,6 +45,7 @@ let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index b8bda4c1b78c..5719dd172cfa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -39,6 +39,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -64,6 +65,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 3ed04e1f2fec..3cb9762cf39f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -49,7 +49,9 @@ import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. @@ -73,6 +75,7 @@ export type AgentSessionMutationSessionPreparation = export type AgentSessionMutationRequest = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter + agents: StructuredAgentRegistry logger: StructuredAgentSessionLogger callerKey: string envelope: AgentSessionMutationEnvelope @@ -175,7 +178,7 @@ export async function admitAndRunAgentSessionMutation( } const fence = record.lease.runtimeFence - const context = turnContext(request, journal, fence) + const context = mutationTurnContext(request, journal, record) if (admission.decision === 'replay') { const replayed = replayRecordedOperation(request, context, admission.row) if (replayed !== 'rerun') { @@ -262,7 +265,7 @@ async function answerRecordedOperation( // The row is gone since: the first-run path decides it from scratch. return 'rerun' } - const context = turnContext(request, journal, current.record.lease.runtimeFence) + const context = mutationTurnContext(request, journal, current.record) return replayRecordedOperation(request, context, current.decision.row) } @@ -317,32 +320,3 @@ function admitWithoutLedgerRow( }) return { admission, record: evaluated.record } } - -function turnContext( - request: AgentSessionMutationRequest, - journal: AgentSessionJournal, - fence: number -): AgentSessionTurnContext { - const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options - return { - sessionId: request.envelope.sessionId, - journal, - fence, - adapter: request.adapter, - logger: request.logger, - ...(persistedOptions ? { persistedOptions } : {}), - persistOptions: (options) => - request.store - .replaceSessionOptions({ - sessionId: request.envelope.sessionId, - fence, - options, - now: request.now() - }) - .then(() => undefined), - resolvedBy: request.callerKey, - publish: () => request.publish(journal), - ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), - now: () => request.now() - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index e77975dc5c22..35b7d5fb322a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -59,6 +59,7 @@ export function mutateStructuredAgentSession( admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts new file mode 100644 index 000000000000..cae4399cb654 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -0,0 +1,38 @@ +// The context an admitted mutation's plan runs in, built from the request that admitted it. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export function mutationTurnContext( + request: AgentSessionMutationRequest, + journal: AgentSessionJournal, + record: AgentSessionRecord +): AgentSessionTurnContext { + const fence = record.lease.runtimeFence + const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options + return { + sessionId: request.envelope.sessionId, + journal, + fence, + adapter: request.adapter, + agents: request.agents, + agent: record.provider, + logger: request.logger, + ...(persistedOptions ? { persistedOptions } : {}), + persistOptions: (options) => + request.store + .replaceSessionOptions({ + sessionId: request.envelope.sessionId, + fence, + options, + now: request.now() + }) + .then(() => undefined), + resolvedBy: request.callerKey, + publish: () => request.publish(journal), + ...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}), + now: () => request.now() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index a5c44bdb0d0a..2c62c3f945da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -19,6 +19,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' async function context(): Promise { return { @@ -35,6 +36,8 @@ async function context(): Promise { stateDirectory: join(hostTestState().root, 'settlement') }), fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: adapter(), persistOptions: async () => {}, resolvedBy: 'test', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index c3e7909b8cf1..55f3a6fbc72f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -6,7 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -50,7 +50,7 @@ describe('an option picked while the chat is at rest', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord }, - adapter: { definition: claudeAndCodexDefinition } + agents: claudeAndCodexAgents() }, { sessionId: SESSION, persistOptions, publish: () => {} }, { key: 'notAnOption', value: 'x' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 624828702713..67085dfbcf22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -23,6 +23,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -126,6 +127,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: router, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 5657fcfb4d8d..fbb3801d0e0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -9,6 +9,7 @@ import { } from '../agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const SESSION = 'session-1' @@ -35,7 +36,12 @@ describe('options at rest', () => { const resting = { child: null, params: { provider: 'codex' } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + deps: { + adapter: {}, + agents: NO_STRUCTURED_AGENTS, + store: { getRecord: () => record }, + modelCatalog + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index e3d75139f6c0..67cc0279294f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -15,7 +15,10 @@ import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structu import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + structuredAgentRewindSupport, + type StructuredAgentRegistry +} from './structured-agent-registry' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -25,21 +28,21 @@ type RestingOptions = Pick, + agents: Pick, record: AgentSessionRecord ): StructuredAgentDefinition['restingOptions'] | null { - return adapter.definition?.(record.provider)?.restingOptions ?? null + return agents.definition(record.provider)?.restingOptions ?? null } async function readStructuredAgentSessionOptionsAtRest( - deps: Pick, + deps: Pick, sessionId: string ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } - const rules = restingOptionRules(deps.adapter, record) + const rules = restingOptionRules(deps.agents, record) const catalog = (await deps.modelCatalog ?.read({ agent: record.provider, sessionId }) .catch(() => null)) ?? { origin: 'unknown' as const } @@ -80,13 +83,13 @@ async function readStructuredAgentSessionOptionsAtRest( export async function recordStructuredAgentSessionOptionIntent( deps: { store: Pick - adapter: Pick + agents: Pick }, ctx: Pick, input: { key: string; value: string } ): Promise> { const record = deps.store.getRecord(ctx.sessionId) - if (!record || !restingOptionRules(deps.adapter, record)?.acceptsKey(input.key)) { + if (!record || !restingOptionRules(deps.agents, record)?.acceptsKey(input.key)) { return { ok: false, refusal: refuse( @@ -110,7 +113,7 @@ export async function readStructuredAgentSessionOptions( >, sessionId: string ): Promise { - const { adapter, store } = context.deps + const { adapter, agents, store } = context.deps const live = await context.serialize(sessionId, async () => { const session = await context.openConversation(sessionId).catch((error: unknown) => { throw journalOpenReadRefusal(error, context.deps.logger, sessionId) @@ -129,16 +132,13 @@ export async function readStructuredAgentSessionOptions( const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase const agent = session.params.provider - const capabilities = adapter.capabilities?.(sessionId, agent) + const capabilities = agents.capabilities(agent) return { ...options, rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } - : (adapter.rewindSupport?.(sessionId, agent) ?? { - supported: false, - reason: 'unsupported' - }), + : structuredAgentRewindSupport(agents, adapter, sessionId, agent), conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), ...(capabilities?.contextUsage diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 8b2586a9ccba..73887ceef69d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -94,6 +95,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 2fa56d3fc6f5..aa1201ed676f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -15,6 +15,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -86,6 +87,8 @@ function context( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c61d2bd117f0..6f0c544f377c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -42,6 +42,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -85,6 +86,7 @@ const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (inpu function startHost(): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 57d249d2dbe4..09421a97cc56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -18,6 +18,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -58,6 +59,7 @@ function createHost( probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] ): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: claudeAdapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 2922c3fdb2ec..18cfaba38fd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -24,6 +24,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -76,6 +77,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f1271f4e3395..2122052a3c7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -74,6 +75,7 @@ export async function createQueuedMessageTestRig( const store = await openTestAgentSessionRecordStore(root) const makeHost = () => new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -131,11 +133,7 @@ export async function createQueuedMessageTestRig( sessionId, clientOperationId, expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId, - fields - }) + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 314e4012199f..772746008e41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -24,6 +24,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -83,6 +84,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index e2b5071d0fa0..bb7732d547f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -31,6 +31,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -90,6 +91,7 @@ async function createHarness(options: { attached?: boolean } = {}) { setOption } const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 74bacfe930d3..96f1a390155f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -1,6 +1,6 @@ -// The router's registrations are the only source of an agent's definition: routing, declared -// capabilities and the option rules a chat at rest reads all follow what composition registered, -// and a definition this build ships but did not register decides nothing. +// The registry is the only source of an agent's definition: routing, declared capabilities and the +// option rules a chat at rest reads all follow what composition registered, and a definition this +// build ships but did not register decides nothing. import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -11,6 +11,7 @@ import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-defin import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions, @@ -50,25 +51,23 @@ function fakeAdapter(): StructuredAgentSessionAdapter { dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), - setOption: vi.fn() + setOption: vi.fn(), + compact: vi.fn(), + changeThreadGoal: vi.fn() } } -function pick( - adapter: StructuredAgentSessionAdapter, - record: AgentSessionRecord | null, - key: string -) { +function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) { const persistOptions = vi.fn(async () => {}) const result = recordStructuredAgentSessionOptionIntent( - { store: { getRecord: () => record }, adapter }, + { store: { getRecord: () => record }, agents }, { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, { key, value: 'enabled' } ) return { result, persistOptions } } -function readAtRest(adapter: StructuredAgentSessionAdapter) { +function readAtRest(agents: StructuredAgentRegistry) { const resting = { child: null, params: { provider: RECORD.provider }, @@ -76,7 +75,11 @@ function readAtRest(adapter: StructuredAgentSessionAdapter) { } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. const context = { - deps: { adapter, store: { getRecord: () => RECORD } }, + deps: { + adapter: new StructuredAgentSessionAdapterRouter(agents, async () => {}), + agents, + store: { getRecord: () => RECORD } + }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => resting, conversation: async () => resting @@ -87,10 +90,8 @@ function readAtRest(adapter: StructuredAgentSessionAdapter) { describe('a registered definition', () => { it('routes, declares capabilities and rules options at rest for its agent', async () => { const adapter = fakeAdapter() - const router = new StructuredAgentSessionAdapterRouter( - [{ definition: NON_DEFAULT, adapter }], - async () => {} - ) + const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) await router.acquire({ identity: { @@ -104,19 +105,19 @@ describe('a registered definition', () => { spawnToken: 'spawn-1' }) expect(adapter.acquire).toHaveBeenCalledOnce() - expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBe(NON_DEFAULT.capabilities) + expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) - const accepted = pick(router, RECORD, 'pilotOption') + const accepted = pick(agents, RECORD, 'pilotOption') await expect(accepted.result).resolves.toMatchObject({ ok: true }) expect(accepted.persistOptions).toHaveBeenCalledWith({ model: 'pilot-model', pilotOption: 'enabled' }) // Claude's own module accepts `model`; the registration says otherwise and wins. - const refused = pick(router, RECORD, 'model') + const refused = pick(agents, RECORD, 'model') await expect(refused.result).resolves.toMatchObject({ ok: false }) - const options = await readAtRest(router) + const options = await readAtRest(agents) expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) // No pick for effort: the registered rules read the model's default. expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' }) @@ -124,31 +125,32 @@ describe('a registered definition', () => { }) it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { - const router = new StructuredAgentSessionAdapterRouter( - [{ definition: CODEX_STRUCTURED_AGENT, adapter: fakeAdapter() }], - async () => {} - ) + const agents = new StructuredAgentRegistry([ + { + definition: CODEX_STRUCTURED_AGENT, + adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() } + } + ]) - expect(router.definition(RECORD.provider)).toBeNull() - expect(router.capabilities(RECORD.sessionId, RECORD.provider)).toBeUndefined() - const refused = pick(router, RECORD, 'model') + expect(agents.definition(RECORD.provider)).toBeNull() + expect(agents.capabilities(RECORD.provider)).toBeNull() + const refused = pick(agents, RECORD, 'model') await expect(refused.result).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } }) expect(refused.persistOptions).not.toHaveBeenCalled() - const options = await readAtRest(router) + const options = await readAtRest(agents) expect(options.models).toEqual([]) expect(options.current).toEqual({ model: 'pilot-model' }) expect(options.conversationCommands).toEqual(['clear']) }) it('refuses a pick for a session with no record, as before', async () => { - const router = new StructuredAgentSessionAdapterRouter( - [{ definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }], - async () => {} - ) - const missing = pick(router, null, 'model') + const agents = new StructuredAgentRegistry([ + { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() } + ]) + const missing = pick(agents, null, 'model') await expect(missing.result).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 97e7e359f6aa..24546a97bcc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -24,7 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -44,31 +44,32 @@ beforeEach(async () => { stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) cancelTurn = vi.fn(async () => ({ cancelled: true })) store = await openTestAgentSessionRecordStore(root) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: fence > 1 ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption, + changeThreadGoal, + stopBackgroundTasks + } host = new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(adapter), logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: fence > 1 ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - answerPrompt: vi.fn(async () => undefined), - setOption, - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities, - stopBackgroundTasks - }, + adapter, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index c0cc13125ca4..7b85104b8a52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -23,6 +23,7 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { startAgent } from './structured-agent-session-restart-interruption-test-harness' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const REQUESTED = 'Cancellation requested.' @@ -112,6 +113,7 @@ describe('a Stop pressed again', () => { await store.renewLeases([]) const relaunchedStore = await openTestAgentSessionRecordStore(root) const relaunched = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: relaunchedStore, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0cd28fafb63d..c16fa93048ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -21,7 +21,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW, @@ -163,6 +163,7 @@ export async function createRestTestRig( } const hostFor = (overrides: Partial) => new StructuredAgentSessionHost({ + agents: claudeAndCodexAgents(), logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -170,8 +171,7 @@ export async function createRestTestRig( releaseAcquisition: vi.fn(async () => true), cancelTurn: async () => ({ cancelled: true }), answerPrompt: async ({ commit }) => commit(), - setOption: async () => undefined, - definition: claudeAndCodexDefinition + setOption: async () => undefined }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 59d478cbb40a..cf442256bf53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -40,6 +40,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -132,6 +133,7 @@ export async function interruptedRestart( const clock = { now: NOW + 1 } const log = recordingProductionStructuredAgentSessionLogger() const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: log.logger, store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 38a47a06ebea..335d5a14686a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -29,6 +29,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -59,6 +60,7 @@ function adapter(): StructuredAgentSessionAdapter { function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index 8d5d5d54cbb8..6776a2f9e898 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -80,6 +81,7 @@ function adapter(): StructuredAgentSessionAdapter { function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 5ee4ca72f691..1e5f1804039d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -31,6 +31,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -94,6 +95,7 @@ beforeEach(async () => { closeSession: async () => true } host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index f8cc4ef26921..a435dad55ba9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -21,6 +21,7 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' import { rewindRefusal } from './structured-rewind-refusal' +import { structuredAgentRewindSupport } from './structured-agent-registry' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' @@ -36,6 +37,7 @@ export async function rewindStructuredAgentSession( const result = await admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope: params.envelope, @@ -64,9 +66,14 @@ export async function rewindStructuredAgentSession( run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) const record = store.getRecord(sessionId)! - const support = ctx.adapter.rewindSupport?.(sessionId) - if (!support?.supported) { - return rewindRefusal(support?.reason ?? 'unsupported') + const support = structuredAgentRewindSupport( + ctx.agents, + ctx.adapter, + sessionId, + ctx.agent + ) + if (!support.supported) { + return rewindRefusal(support.reason) } if ( record.rewind?.phase === 'prepared' || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 55acadfc562f..a88cfc96e52b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -12,6 +12,7 @@ import { performSend, type AgentSessionTurnContext } from './structured-agent-se import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const journals = createTrackedJournalOpener() @@ -63,6 +64,8 @@ describe('structured send idempotency', () => { sessionId: 'session-1', journal, fence: 2, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'caller', @@ -104,6 +107,8 @@ describe('structured send idempotency', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'caller', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index c30c33d1f434..631b9cac3c37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -71,6 +72,7 @@ async function relaunchAfterCrashMidTurn( throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), acquire }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index c51219ae8e64..f1bbd673cd62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -72,6 +73,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => hostErrors.push(fields.error), error: (_message, fields) => hostErrors.push(fields.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 6cf15ca088c7..f41d2e190c7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -31,6 +31,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -148,6 +149,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 8298ac67daf9..2a7319c595a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -28,6 +28,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -109,6 +110,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => accepted()) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -137,6 +139,7 @@ describe('settled attach retry', () => { .mockRejectedValueOnce(new Error('journal path unavailable')) .mockResolvedValue(null) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { ...adapter(), historyFilePath }, @@ -195,6 +198,7 @@ describe('settled attach retry', () => { }) const mintSpawnToken = vi.fn(() => 'spawn-safe') host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -240,6 +244,7 @@ describe('settled attach retry', () => { let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -271,6 +276,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -329,6 +335,7 @@ describe('settled attach retry', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 0afc0a05077d..77b48a375a47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -29,6 +29,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -122,6 +123,7 @@ beforeEach(async () => { ) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index b71022b92c70..afd28054d744 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -30,6 +30,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const writes = vi.hoisted(() => ({ failing: false })) @@ -84,6 +85,7 @@ async function relaunch( // The lease-reconcile entries the host logs, by the failure each reports. const leaseReconcileLogged = vi.fn() const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => { if (fields.scope === 'lease-reconcile') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index cce95e1ac0b9..de67edded0ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -42,6 +42,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -77,6 +78,7 @@ function openHost( probeOwner?: (record: AgentSessionRecord) => Promise ): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: { warn: (_message, fields) => hostErrors.push(fields.error), error: (_message, fields) => hostErrors.push(fields.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 5a95d7c90b21..2c45cc73a416 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -16,10 +16,9 @@ import { threadGoalPlan } from './structured-agent-session-thread-goal' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -68,15 +67,19 @@ function appendGoalRow( function context( journal: AgentSessionJournal, - adapter: Partial + double: Partial, + agent = 'codex' ): AgentSessionTurnContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods. + const adapter = double as StructuredAgentSessionAdapter return { logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', journal, fence: 1, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods. - adapter: adapter as StructuredAgentSessionAdapter, + adapter, + agents: claudeAndCodexAgents(adapter), + agent, persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), @@ -91,8 +94,7 @@ describe('performThreadGoalChange', () => { const result = await performThreadGoalChange( context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }), { clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } } ) @@ -121,8 +123,7 @@ describe('performThreadGoalChange', () => { it('removes the objective when the provider refuses the goal', async () => { const journal = await openJournal() const ctx = context(journal, { - changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }), - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }) }) const result = await performThreadGoalChange(ctx, { @@ -146,8 +147,7 @@ describe('performThreadGoalChange', () => { const ctx = context(journal, { changeThreadGoal: async () => { throw new Error('connection closed') - }, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + } }) await expect( @@ -168,8 +168,7 @@ describe('performThreadGoalChange', () => { async () => ({ ok: true as const }) ] const ctx = context(journal, { - changeThreadGoal: () => attempts.shift()!(), - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal: () => attempts.shift()!() }) const input = { clientOperationId: 'op-9', @@ -198,8 +197,7 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const ctx = context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }) await performThreadGoalChange(ctx, { @@ -216,13 +214,10 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - const result = await performThreadGoalChange( - context(journal, { - changeThreadGoal, - capabilities: () => CLAUDE_STRUCTURED_AGENT.capabilities - }), - { clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } } - ) + const result = await performThreadGoalChange(context(journal, { changeThreadGoal }, 'claude'), { + clientOperationId: 'op-5', + change: { kind: 'set', objective: 'Ship it' } + }) expect(result).toMatchObject({ ok: false, @@ -244,8 +239,7 @@ describe('performThreadGoalChange', () => { await appendGoalRow(journal, { status: 'complete' }) const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const ctx = context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }) await performThreadGoalChange(ctx, { @@ -277,8 +271,7 @@ describe('performThreadGoalChange', () => { const journal = await openJournal() const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) const ctx = context(journal, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities + changeThreadGoal }) // Issued, not yet landed: the set's read takes its place behind it in the journal's queue. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 1ab4e5cfaa6e..0948a86e8fd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -47,7 +47,7 @@ export async function performThreadGoalChange( ctx: AgentSessionTurnContext, input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { - if (!ctx.adapter.changeThreadGoal || !ctx.adapter.capabilities?.(ctx.sessionId)?.threadGoal) { + if (!ctx.adapter.changeThreadGoal || !ctx.agents.capabilities(ctx.agent)?.threadGoal) { return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 0cbf5b8ce6c1..b79191c9ac39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -13,6 +13,7 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -58,6 +59,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -112,6 +115,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -157,6 +162,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn: vi.fn(async () => ({ cancelled: false })) } as unknown as StructuredAgentSessionAdapter, @@ -191,6 +198,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -229,6 +238,8 @@ describe('performCancel', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -310,6 +321,8 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), @@ -393,6 +406,8 @@ describe('the note a Stop writes', () => { sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 629d7e717903..49bf40eec6f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -43,6 +43,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -52,6 +53,10 @@ export type AgentSessionTurnContext = { journal: AgentSessionJournal fence: number adapter: StructuredAgentSessionAdapter + /** What each agent declares; the session's own answer is `agents.capabilities(agent)`. */ + agents: StructuredAgentRegistry + /** The session's agent. */ + agent: string logger: StructuredAgentSessionLogger persistedOptions?: Readonly> persistOptions: (options: Readonly>) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 20c5abd595e8..8f6037c6d9c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -26,6 +26,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -85,6 +86,7 @@ beforeEach(async () => { }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 242f7c228778..768b6724caa2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -55,6 +55,7 @@ import { claudeProviderHandle, codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -126,6 +127,7 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise { /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ function openHost(overrides: Partial = {}): void { host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 828128bc2b71..d2cb969bf1d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -9,7 +9,7 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { claudeAndCodexDefinition } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter @@ -67,6 +67,7 @@ let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { store = await openTestAgentSessionRecordStore(generationRoot()) host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, @@ -102,7 +103,6 @@ beforeEach(async () => { compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) adapter = { - definition: claudeAndCodexDefinition, supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, acquire: vi.fn(async (input) => { 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 a466d00d2b5e..9672df6a4879 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -106,6 +106,7 @@ export function runStructuredConversationCommand( return admitAndRunAgentSessionMutation({ store, adapter: context.deps.adapter, + agents: context.deps.agents, logger: context.deps.logger, callerKey: caller.callerKey, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 41031cb3b3eb..6113b17ddec6 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -24,8 +24,12 @@ import { attach, CALLER, envelope, - hostTestState + hostTestState, + replaceHostTestState } from './structured-agent-session-host-test-harness' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { StructuredAgentRegistry } from './structured-agent-registry' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' import { HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, @@ -333,6 +337,33 @@ it('says only that the compaction failed when the provider refused it without wo ) }) +it('refuses the command for an agent that does not declare compaction, whatever its adapter has', async () => { + const declared = CODEX_STRUCTURED_AGENT.capabilities + const agents = new StructuredAgentRegistry([ + { + definition: { + ...CODEX_STRUCTURED_AGENT, + capabilities: { ...declared, compact: false, threadGoal: false, rewind: false } + }, + adapter: state.host.deps.adapter + } + ]) + replaceHostTestState({ + store: state.store, + host: new StructuredAgentSessionHost({ ...state.host.deps, agents }) + }) + state = hostTestState() + await attach() + const params = compactParams() + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', failure: { kind: 'commandRefused' } } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() +}) + it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() const events = state.acquire.mock.calls.at(-1)?.[0].events diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 1caa929185b6..cb360070191e 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -220,10 +220,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: { - variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: adoption ? adoption.accountHomePath : selectedAccountHomePath - }, + accountHome: agentSessionAccountHome( + input.agent, + adoption ? adoption.accountHomePath : selectedAccountHomePath + ), ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 1992d3c785a0..f1faf8e1b388 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -16,6 +16,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -187,6 +188,7 @@ describe('committed adopting create RPC replay', () => { const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: sessionAdapter, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 44e0e74ab667..9ff3979b5586 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 @@ -33,8 +33,7 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' -import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' +import { claudeAndCodexDeclared } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CLIENT = { clientId: 'device-1', @@ -372,11 +371,9 @@ describe('options at rest', () => { it('answers the provider-level features of a chat at rest (P2-17)', async () => { await restingChat() + // A runtime that declares Codex's goal and rewind, as production registers it. + setStructuredAgentSessionHost(await rig.restart({ agents: claudeAndCodexDeclared() })) Object.assign(rig.host.deps.adapter, { - capabilities: (_id: string, agent?: string) => - agent === 'codex' - ? CODEX_STRUCTURED_AGENT.capabilities - : CLAUDE_STRUCTURED_AGENT.capabilities, rewindSupport: (_id: string, agent?: string) => agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 7b13887e1aa4..549b0a92dd26 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../../shared/agent-session-failu import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const CONNECTION = 'connection-1' const CLIENT = { @@ -81,6 +82,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 6100749b7cc1..0901f6ffa5d4 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -41,6 +41,7 @@ import { closeStructuredAgentSessionChild } from '../../structured-agent-session import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -95,6 +96,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { async function openHost(): Promise { store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: providerAdapter(), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 49665fd27c39..8fbd90faca79 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -34,6 +34,7 @@ import { type StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { readClaudeManagedAccountGateSettings, @@ -303,18 +304,17 @@ async function installOnJournal( ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore }) - const adapter = new StructuredAgentSessionAdapterRouter( - [ - { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, - { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } - ], - async () => { - await Promise.all([codex.closeAll(), claude.closeAll()]) - } - ) + const agents = new StructuredAgentRegistry([ + { definition: CODEX_STRUCTURED_AGENT, adapter: codex }, + { definition: CLAUDE_STRUCTURED_AGENT, adapter: claude } + ]) + const adapter = new StructuredAgentSessionAdapterRouter(agents, async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + }) host = new StructuredAgentSessionHost({ store, adapter, + agents, recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory), journalDatabase, claimKeyId: deps.claimKeyId, diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 4e29e628d36d..52649785c59f 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -11,7 +11,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionRecord } from '../../shared/agent-session-record' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { fakeClaude } from '../claude/claude-structured-session-test-support' -import { claudeAndCodexRouter } 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' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { @@ -122,11 +123,13 @@ beforeEach(async () => { onLifecycleEvent: () => {} }) log = recordingStructuredAgentSessionLogger() + // Only Claude sessions are attached here; the router supplies the production create gate. + const agents = claudeAndCodexAgents({ claude: adapter, codex: adapter }) host = new StructuredAgentSessionHost({ + agents, logger: log.logger, store, - // Only Claude sessions are attached here; the router supplies the production create gate. - adapter: claudeAndCodexRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll()), + adapter: new StructuredAgentSessionAdapterRouter(agents, () => adapter.closeAll()), journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx index fcb474f2acc6..b3f4a938fb25 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -94,4 +94,19 @@ describe('useStructuredAgentSession transcript', () => { // The list places rows by the same items, so the notice joins the cut turn. expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) }) + + it('names any other agent by its own label, never as Claude', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'gemini', + target: { kind: 'local' }, + isVisible: true + }) + ) + + expect(result.current.messages.at(-1)).toMatchObject({ + blocks: [{ text: expect.stringMatching(/^Gemini stopped while/) }] + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 839a384fa441..e8d0efdd7278 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -39,7 +39,6 @@ import { outboxOutsideQueuedCards } from './structured-agent-session-queued-card import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -170,7 +169,7 @@ export function useStructuredAgentSession(args: { const transcriptItems = useMemo( () => withNativeChatCutTurnNotices(transportState.journalItems, { - agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + agentName: structuredAgentLabel(agent) }), [agent, transportState.journalItems] ) diff --git a/src/shared/agent-session-turn-record.test.ts b/src/shared/agent-session-turn-record.test.ts index bcd14a7290fd..d31a36d1c764 100644 --- a/src/shared/agent-session-turn-record.test.ts +++ b/src/shared/agent-session-turn-record.test.ts @@ -110,6 +110,22 @@ describe('legacyAgentJournalTurnStatusBody', () => { ).toBe('Codex turn outcome unverifiable') }) + it('names any other agent by its own name, never as Codex', () => { + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'gemini').text).toBe( + 'Gemini turn completed' + ) + expect(legacyAgentJournalTurnStatusBody(turn, 'legacy:gemini:s:turn-lifecycle%3At1').text).toBe( + 'Gemini turn completed' + ) + // Nothing names the agent: say so rather than guess one. + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1').text).toBe( + 'Agent turn completed' + ) + expect(legacyAgentJournalTurnStatusBody(turn, 'orca:command-turn:t1', 'codex').text).toBe( + 'Codex turn completed' + ) + }) + it('carries the verdict to a client that predates the turn item', () => { // The downgrade is the only carrier an old client gets. Its own text still // reads "completed" — that copy is the pre-existing contract and this change diff --git a/src/shared/agent-session-turn-record.ts b/src/shared/agent-session-turn-record.ts index 2932eef5c1fc..eb4b6c49a56a 100644 --- a/src/shared/agent-session-turn-record.ts +++ b/src/shared/agent-session-turn-record.ts @@ -11,6 +11,7 @@ import { type AgentJournalTurnOutcome } from './agent-session-journal-types' import { agentTurnLifecycleText } from './agent-turn-lifecycle-text' +import { tuiAgentDisplayName } from './tui-agent-display-names' export function readAgentJournalTurn( body: AgentJournalItemBody | undefined @@ -67,9 +68,7 @@ export function legacyAgentJournalTurnStatusBody( itemId: string, sessionAgent?: string | null ): AgentJournalStatusItem { - const agent = - (sessionAgent ?? (itemId.startsWith('legacy:claude:') ? 'claude' : 'codex')) === 'claude' - ? 'Claude' - : 'Codex' - return { kind: 'status', text: agentTurnLifecycleText(agent, turn.state), turnLifecycle: turn } + const agent = sessionAgent ?? /^legacy:([^:]+):/.exec(itemId)?.[1] ?? null + const name = agent === null ? 'Agent' : (tuiAgentDisplayName(agent) ?? agent) + return { kind: 'status', text: agentTurnLifecycleText(name, turn.state), turnLifecycle: turn } } diff --git a/src/shared/agent-turn-lifecycle-text.ts b/src/shared/agent-turn-lifecycle-text.ts index f8bc2be568b5..3f641f27ad96 100644 --- a/src/shared/agent-turn-lifecycle-text.ts +++ b/src/shared/agent-turn-lifecycle-text.ts @@ -3,7 +3,7 @@ import type { AgentJournalTurnLifecycleState } from './agent-session-journal-typ /** Fallback text on a lifecycle row for readers that render status text raw. * Must never overstate what the host knows: an unobserved end is not "completed". */ export function agentTurnLifecycleText( - agent: 'Claude' | 'Codex', + agent: string, state: AgentJournalTurnLifecycleState ): string { switch (state) { diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index a780cff5a4e9..0ea071e4e17d 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -56,3 +56,8 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { /** Canonical agent id list derived from the exhaustive display-name record, * so shared modules can enumerate agents without importing renderer code. */ export const ALL_TUI_AGENTS = Object.keys(TUI_AGENT_DISPLAY_NAMES) as readonly TuiAgent[] + +/** The display name of `agent`, or null when it names no TUI agent. */ +export function tuiAgentDisplayName(agent: string): string | null { + return Object.entries(TUI_AGENT_DISPLAY_NAMES).find(([id]) => id === agent)?.[1] ?? null +} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index d427eb5109a8..f1076ac1df4d 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -65,6 +65,7 @@ import { import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -550,6 +551,7 @@ describe('cross-version structured agent sessions', () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: { @@ -777,6 +779,7 @@ describe('cross-version structured agent sessions', () => { async function bootHost(generation: string): Promise { store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index abf2f994004a..9fc2255514b2 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -27,6 +27,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-ty import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding' +import { NO_STRUCTURED_AGENTS } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -38,6 +39,7 @@ let closeSession: Mock Date: Mon, 5 Oct 2026 03:18:49 -0700 Subject: [PATCH 22/45] refactor(agent-session): the router applies the declared rewind itself The router holds the registry, so its rewindSupport answers the owner's declared rewind narrowed by the adapter, in one place no reader can bypass. The host-side combining helper is gone; readers ask the adapter they hold. --- .../structured-agent-registry.ts | 14 ------------ ...nt-session-adapter-router-registry.test.ts | 22 +++++++++++-------- ...structured-agent-session-adapter-router.ts | 4 ++-- .../structured-agent-session-adapter.ts | 2 +- .../structured-agent-session-options-read.ts | 10 ++++----- .../structured-agent-session-rewind.ts | 12 +++------- 6 files changed, 24 insertions(+), 40 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts index 21e29c008ab7..c6f305548ce9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-registry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-registry.ts @@ -5,7 +5,6 @@ // declared capability always has the adapter method behind it. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' -import type { AgentSessionRewindSupport } from '../../../shared/agent-session-rewind' import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -71,16 +70,3 @@ export class StructuredAgentRegistry { return this.definition(agent)?.capabilities ?? null } } - -/** Whether this session can rewind now: its agent's declaration, narrowed by the adapter. */ -export function structuredAgentRewindSupport( - agents: StructuredAgentRegistry, - adapter: Pick, - sessionId: string, - agent: string -): AgentSessionRewindSupport { - if (!agents.capabilities(agent)?.rewind) { - return { supported: false, reason: 'unsupported' } - } - return adapter.rewindSupport?.(sessionId, agent) ?? { supported: true } -} 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 a0564157810b..6ebb9bdcd79a 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 @@ -10,7 +10,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry, structuredAgentRewindSupport } from './structured-agent-registry' +import { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentDefinition } from './structured-agent-definition' const LOCAL: AgentSessionExecutionLocation = { @@ -149,22 +149,26 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) }) - it('lets a session narrow a declared rewind but never widen an undeclared one', () => { + it('lets a session narrow a declared rewind but never widen an undeclared one', async () => { const narrowing = declaringAdapter({ rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) }) const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) - const agents = claudeAndCodex({ codex: narrowing, claude: widening }) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const router = new StructuredAgentSessionAdapterRouter( + claudeAndCodex({ codex: narrowing, claude: widening }), + async () => {} + ) + const unsupported = { supported: false, reason: 'unsupported' } - expect(structuredAgentRewindSupport(agents, router, 'session-1', 'codex')).toEqual({ + expect(router.rewindSupport('session-1', 'codex')).toEqual({ supported: false, reason: 'history-not-paginated' }) - expect(structuredAgentRewindSupport(agents, router, 'session-1', 'claude')).toEqual({ - supported: false, - reason: 'unsupported' - }) + // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. + expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) + await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) + expect(router.rewindSupport('session-1')).toEqual(unsupported) + expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b7191d50f54c..689381b4ffff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -71,13 +71,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - /** The owning adapter's narrowing for this session; the host combines it with the declaration. */ + /** The owner's declared rewind, narrowed by its adapter for this session; never widened. */ rewindSupport: NonNullable = ( sessionId, agent ) => { const owner = this.capabilityOwner(sessionId, agent) - if (!owner) { + if (!owner?.definition.capabilities.rewind) { return { supported: false, reason: 'unsupported' } } return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5bd42f2b2eb2..e5ac31026ee8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -266,7 +266,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & beforeDispatch?: () => Promise }): Promise /** How this session narrows its agent's declared rewind; `agent` answers for one with no child - * running. The host reads the declaration first: this never widens it. */ + * running. The router applies the declaration first, so an adapter's answer never widens it. */ rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 67cc0279294f..314f436f1d25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -15,10 +15,7 @@ import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structu import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' -import { - structuredAgentRewindSupport, - type StructuredAgentRegistry -} from './structured-agent-registry' +import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -138,7 +135,10 @@ export async function readStructuredAgentSessionOptions( rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } - : structuredAgentRewindSupport(agents, adapter, sessionId, agent), + : (adapter.rewindSupport?.(sessionId, agent) ?? { + supported: false, + reason: 'unsupported' + }), conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'], ...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}), ...(capabilities?.contextUsage diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index a435dad55ba9..7ccf8f7bbed9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -21,7 +21,6 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import { conversationCommandBlocked } from './structured-conversation-command-admission' import { rewindRefusal } from './structured-rewind-refusal' -import { structuredAgentRewindSupport } from './structured-agent-registry' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' @@ -66,14 +65,9 @@ export async function rewindStructuredAgentSession( run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) const record = store.getRecord(sessionId)! - const support = structuredAgentRewindSupport( - ctx.agents, - ctx.adapter, - sessionId, - ctx.agent - ) - if (!support.supported) { - return rewindRefusal(support.reason) + const support = ctx.adapter.rewindSupport?.(sessionId, ctx.agent) + if (!support?.supported) { + return rewindRefusal(support?.reason ?? 'unsupported') } if ( record.rewind?.phase === 'prepared' || From b494ed62d8e71f06ebcf2026511f4e608a7949b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:37:13 -0700 Subject: [PATCH 23/45] test(agent-session): register the agents the merged-in tests now need The adoption replay test builds its host over this build's registered agents instead of an adapter definition method, and the stored-form test passes the stored agents the record guard now requires. --- .../structured-agent-session-adoption-replay.test.ts | 6 ++---- src/shared/agent-session-record-stored-form.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) 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 536af042fe46..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 @@ -8,7 +8,6 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/ import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { claudeAndCodexDefinition } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' @@ -17,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' @@ -35,7 +34,6 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, - definition: claudeAndCodexDefinition, acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ @@ -190,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/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index 1d075ae10539..c78f190a4288 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -242,8 +242,8 @@ describe('what this build writes', () => { stored.providerHandleChain[0].handle.laterHandleField = 'x' stored.providerHandleChain[0].laterLinkField = 'y' stored.laterRecordField = 'z' - expect(isPersistedAgentSessionRecord(stored)).toBe(true) - if (!isPersistedAgentSessionRecord(stored)) { + expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) + if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { return } const written = JSON.parse( From 7591167f833520fe089f1e1ddf44710431568230 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:39:11 -0700 Subject: [PATCH 24/45] chore(agent-session): the registry is the one lookup; drop the test-only empty capability record --- .../agent-session-wire/structured-agent-definition.ts | 4 ++-- ...ured-agent-session-adapter-router-registry.test.ts | 7 +++++-- src/shared/agent-session-capabilities.ts | 11 ----------- 3 files changed, 7 insertions(+), 15 deletions(-) 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 89614e66e6fd..8744ccfde1fc 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 @@ -1,8 +1,8 @@ // What the host knows about a structured agent before any session of it runs. // // Each agent's own module declares its definition, and runtime composition registers it with the -// agent's adapter. The router's registrations are the only lookup: shared host code reads a -// definition through them and never branches on the agent's name. +// agent's adapter in one StructuredAgentRegistry. That registry is the only lookup: the router and +// shared host code read a definition through it and never branch on the agent's name. import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities' import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' 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 6ebb9bdcd79a..c16c035ef6d8 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 @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { NO_AGENT_SESSION_CAPABILITIES } from '../../../shared/agent-session-capabilities' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' @@ -61,7 +60,11 @@ function fakeAdapter( const PILOT: StructuredAgentDefinition = { agent: 'grok', capabilities: { - ...NO_AGENT_SESSION_CAPABILITIES, + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts: false, steering: 'queue', approvalEnforcement: 'orca' }, diff --git a/src/shared/agent-session-capabilities.ts b/src/shared/agent-session-capabilities.ts index 78d63ef70d60..285fcfe2d457 100644 --- a/src/shared/agent-session-capabilities.ts +++ b/src/shared/agent-session-capabilities.ts @@ -27,14 +27,3 @@ export type AgentSessionCapabilities = { */ approvalEnforcement: 'provider' | 'orca' } - -/** The answer for an agent no definition covers: nothing beyond a plain chat, claimed by no one. */ -export const NO_AGENT_SESSION_CAPABILITIES: AgentSessionCapabilities = Object.freeze({ - rewind: false, - compact: false, - threadGoal: false, - contextUsage: false, - imagePrompts: false, - steering: 'queue', - approvalEnforcement: 'orca' -}) From 4f32dc9049e92919e3bd365f7b79f13a83675e7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:34:05 -0700 Subject: [PATCH 25/45] fix(agent-session): keep "dismiss all" restart offers dismissed for the desktop The desktop renderer calls the restart methods as a paired runtime client without the registered-agents capability, so it was handed a Claude/Codex audience and its "dismiss all" took the scoped path: no dismissal fence and no clearing of unwritten teardown witnesses, so a late teardown write could bring a dismissed offer back. The audience is now derived against this host's registry: a client that can show every agent the host registered gets none, and dismisses exactly as before (one fence for every offer). A client that truly cannot show some agent still gets a final dismissal for what it sees: each cleared session is fenced on its own (bounded, superseded by a dismiss-all fence) and only the witnesses of agents it sees are dropped. One predicate now answers which agents a client renders, for both tab projection and restart offers; the Claude capability rule is folded in. --- ...red-agent-session-restart-audience.test.ts | 3 +- ...ed-agent-session-restart-failure-ledger.ts | 26 ++--- ...ssion-restart-interruption-test-harness.ts | 9 +- ...tured-agent-session-restart-resume-host.ts | 2 +- ...ed-agent-session-restart-witnesses.test.ts | 52 +++++++++ ...uctured-agent-session-restart-witnesses.ts | 24 +++-- .../agent-session-recovery-capsule-entries.ts | 102 +++++++++++++++++- .../agent-session-recovery-capsule.test.ts | 53 +++++++++ .../runtime/agent-session-recovery-capsule.ts | 51 ++++----- .../session-tab-agent-status-projection.ts | 20 +--- .../structured-agent-session-policy.ts | 44 +++++--- ...gent-session-restart-dismiss-fence.test.ts | 102 ++++++++++++++++++ ...tured-agent-session-restart-resume.test.ts | 19 +++- ...structured-agent-session-restart-resume.ts | 6 +- 14 files changed, 425 insertions(+), 88 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts 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 index f468d0bd12ee..909e7583e08c 100644 --- 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 @@ -74,6 +74,7 @@ it('keeps a failure the caller cannot show, and leaves it out of an action reply 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) + // A dismiss-all counts pending offers, as it always has; the failure goes with it all the same. + expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(0) expect(await host.restartResume.listFailures()).toEqual([]) }) 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 0b217d58303b..8c986463e82b 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 @@ -59,11 +59,11 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. With an - * audience, only records of agents it sees go, and an unnamed dismissal is no fence. */ + /** Named sessions forget their offer or failure; unnamed, every durable record goes and stays + * gone. With an audience, only records of agents it sees go, and only they are fenced. */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void, + beforeClearAll: (audience?: StructuredAgentSessionRestartAudience) => void, audience?: StructuredAgentSessionRestartAudience ) => Promise } @@ -216,20 +216,20 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { settle, 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. - const hidden = (marker: AgentSessionResumeMarker) => { - const record = deps.getRecord(marker.sessionId) - return record === null || !audience(record.provider) + // Decided under the capsule lock. A record whose chat this host cannot read names no + // agent the audience was shown, so it stays. + const hidden = (marker: AgentSessionResumeMarker) => { + if (!audience) { + return false } - return (await deps.capsule?.dismiss(sessionIds ?? 'all', deps.now(), hidden)) ?? 0 + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) } if (sessionIds !== undefined) { - return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 + return (await deps.capsule?.dismiss(sessionIds, deps.now(), hidden)) ?? 0 } - beforeClearAll() - return (await deps.capsule?.clearAll(deps.now())) ?? 0 + beforeClearAll(audience) + return (await deps.capsule?.clearAll(deps.now(), audience ? hidden : undefined)) ?? 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-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 3e30b0baf92c..ac91a00f8723 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 @@ -179,7 +179,7 @@ export function createStructuredAgentSessionRestartResume( ) => { // 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)) 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/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index a5d1d899723a..2d7bfa4b24e5 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -42,8 +42,12 @@ const capsuleSchema = z.object({ version: z.literal(2), entries: z.array(z.unknown()), dismissedAt: z.number().int().nonnegative().optional(), + dismissedSessions: z.unknown().optional(), failed: z.unknown().optional() }) +const sessionFencesSchema = z.record(z.string().min(1), z.number().int().nonnegative()) +/** Newest per-session fences kept; a dismiss-all fence supersedes every older one. */ +const MAX_SESSION_FENCES = 512 /** What an acted-on offer left behind when the agent did not carry on. Current only while nothing * newer happened in that chat; also dies with a dismissal, a successful retry, or a newer @@ -79,7 +83,101 @@ export type RecoveryEntry = { export type RecoveryCapsuleState = { entries: RecoveryEntry[] failed: AgentSessionResumeFailureRecord[] +} & RecoveryCapsuleFence + +/** What keeps a dismissed offer dismissed: a teardown witness recorded at or before the fence is + * dropped. `dismissedAt` fences every session (dismiss-all); `dismissedSessions` fences single + * sessions, for a dismiss-all scoped to the agents one client sees. */ +export type RecoveryCapsuleFence = { + dismissedAt?: number + dismissedSessions?: ReadonlyMap +} + +export function isFencedOut( + fence: RecoveryCapsuleFence, + marker: AgentSessionResumeMarker +): boolean { + const at = Math.max(fence.dismissedAt ?? -1, fence.dismissedSessions?.get(marker.sessionId) ?? -1) + return marker.recordedAt <= at +} + +/** `fence` with each of `sessionIds` fenced at `at`. */ +function withSessionFences( + fence: RecoveryCapsuleFence, + sessionIds: Iterable, + at: number +): RecoveryCapsuleFence { + const dismissedSessions = new Map(fence.dismissedSessions) + for (const sessionId of sessionIds) { + dismissedSessions.set(sessionId, Math.max(at, dismissedSessions.get(sessionId) ?? at)) + } + return { ...fence, dismissedSessions } +} + +/** A dismiss-all of `state`: every record but those `keep` answers true for goes, and stays gone. + * An in-flight action's later complete/rollback becomes a no-op, and a late teardown writer is + * fenced: for every session, or with `keep` for each cleared one, so a session kept for a client + * that sees it is never fenced by one that does not. */ +export function dismissAll( + state: RecoveryCapsuleState, + now: number, + keep?: (marker: AgentSessionResumeMarker) => boolean +): { + records: Pick + fence: RecoveryCapsuleFence + pendingCleared: number +} { + const { entries, failed } = normalizeState(state, now) + const kept = (record: { marker: AgentSessionResumeMarker }) => keep?.(record.marker) === true + const cleared = [...entries, ...failed].filter((record) => !kept(record)) + return { + records: { entries: entries.filter(kept), failed: failed.filter(kept) }, + fence: keep + ? withSessionFences( + state, + cleared.map((record) => record.marker.sessionId), + now + ) + : { dismissedAt: now }, + pendingCleared: entries.filter((entry) => entry.state === 'pending' && !kept(entry)).length + } +} + +/** The capsule file's contents. */ +export function storedCapsule( + { entries, failed }: Pick, + fence: RecoveryCapsuleFence +) { + return { + version: 2, + entries, + ...storedFence(fence), + ...(failed.length === 0 ? {} : { failed }) + } +} + +/** The fence as stored: a session fence the dismiss-all fence covers is dropped, and only the + * newest are kept, so the set cannot grow without bound. */ +function storedFence(fence: RecoveryCapsuleFence): { dismissedAt?: number + dismissedSessions?: Record +} { + const sessions = [...(fence.dismissedSessions ?? new Map())] + .filter(([, at]) => fence.dismissedAt === undefined || at > fence.dismissedAt) + .sort(([, left], [, right]) => right - left) + .slice(0, MAX_SESSION_FENCES) + return { + ...(fence.dismissedAt === undefined ? {} : { dismissedAt: fence.dismissedAt }), + ...(sessions.length === 0 ? {} : { dismissedSessions: Object.fromEntries(sessions) }) + } +} + +// Advisory like failures: an unreadable set reads as none rather than costing every offer. +function parseSessionFences(value: unknown): ReadonlyMap | undefined { + const parsed = sessionFencesSchema.safeParse(value) + return parsed.success && Object.keys(parsed.data).length > 0 + ? new Map(Object.entries(parsed.data)) + : undefined } function parseMarker(value: unknown): AgentSessionResumeMarker { @@ -144,10 +242,12 @@ export function parseState(raw: string): RecoveryCapsuleState { } } const capsule = capsuleSchema.parse(value) + const dismissedSessions = parseSessionFences(capsule.dismissedSessions) return { entries: capsule.entries.map(parseEntry), failed: parseFailures(capsule.failed), - ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }) + ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }), + ...(dismissedSessions ? { dismissedSessions } : {}) } } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index bb3c93a6a989..8d617f115534 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -516,6 +516,59 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW + 2)).toEqual([newer]) }) + it('fences each session a scoped dismiss-all clears, and leaves what it keeps unfenced', async () => { + const hidden = marker({ sessionId: 'hidden' }) + await capsule.record([marker(), hidden], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + + expect(await capsule.clearAll(NOW + 1, (kept) => kept.sessionId === 'hidden')).toBe(0) + expect(await capsule.list(NOW + 1)).toEqual([hidden]) + await capsule.rollbackResume('operation-a', NOW + 1) + // The stale teardown write of the cleared chat stays out; the kept chat still takes a newer one. + const newerHidden = marker({ sessionId: 'hidden', recordedAt: NOW + 1, teardownId: 'later' }) + await capsule.record([marker(), newerHidden], NOW + 1) + expect(await capsule.list(NOW + 1)).toEqual([newerHidden]) + + const newer = marker({ recordedAt: NOW + 2, teardownId: 'teardown-new' }) + await capsule.record([newer], NOW + 2) + expect(await capsule.list(NOW + 2)).toContainEqual(newer) + await capsule.record([marker()], NOW + 2) + expect(await capsule.list(NOW + 2)).toContainEqual(newer) + }) + + it('drops session fences a dismiss-all fence covers, and keeps the newest of the rest', async () => { + await capsule.record([marker()], NOW) + await capsule.clearAll(NOW, () => false) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ + dismissedSessions: { [SESSION]: NOW } + }) + + await capsule.clearAll(NOW + 1) + expect(JSON.parse(await readFile(filePath, 'utf8'))).not.toHaveProperty('dismissedSessions') + + const many = Array.from({ length: 600 }, (_, index) => + marker({ sessionId: `session-${index}`, recordedAt: NOW + 2 + index }) + ) + await capsule.record(many, NOW + 2) + for (const [index, each] of many.entries()) { + await capsule.clearAll(NOW + 2 + index, (kept) => kept.sessionId !== each.sessionId) + } + const fences = JSON.parse(await readFile(filePath, 'utf8')).dismissedSessions + expect(Object.keys(fences)).toHaveLength(512) + expect(fences).toHaveProperty('session-599') + expect(fences).not.toHaveProperty('session-0') + }) + + it('lets a scoped dismiss-all replace an unreadable file with an empty fence', async () => { + await writeFile(filePath, '{') + + expect(await capsule.clearAll(NOW, () => true)).toBe(0) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ + entries: [], + dismissedAt: NOW + }) + }) + it('keeps a legacy v1 file readable until a mutating operation migrates it', async () => { const legacy = JSON.stringify({ version: 1, markers: [marker()] }) await writeFile(filePath, legacy) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 7343ab64e8e1..ee7002558e58 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -11,10 +11,14 @@ import { } from '../durable-file-write' import { withFileTransactionLock } from '../file-transaction-lock' import { + dismissAll, + isFencedOut, MAX_FAILURE_FIELD_LENGTH, normalizeState, parseState, shouldReplaceMarker, + storedCapsule, + type RecoveryCapsuleFence, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, type RecoveryCapsuleState, @@ -64,9 +68,8 @@ export class AgentSessionRecoveryCapsule { const { entries, failed } = normalizeState(state, now) const bySession = new Map(entries.map((entry) => [entry.marker.sessionId, entry])) const failedBySession = new Map(failed.map((failure) => [failure.marker.sessionId, failure])) - const dismissedAt = state.dismissedAt for (const marker of markers) { - if (dismissedAt !== undefined && marker.recordedAt <= dismissedAt) { + if (isFencedOut(state, marker)) { continue } const existing = bySession.get(marker.sessionId) @@ -92,7 +95,7 @@ export class AgentSessionRecoveryCapsule { } // Keep the fence after a newer interruption. It still admits genuinely newer markers, // while an older delayed writer remains unable to resurrect a dismissed chat later. - await this.publish({ entries: [...bySession.values()], failed }, now, dismissedAt) + await this.publish({ entries: [...bySession.values()], failed }, now, state) }) } @@ -125,7 +128,7 @@ export class AgentSessionRecoveryCapsule { next.push(reserve(failure.marker)) } } - await this.publish({ entries: next, failed }, now, state.dismissedAt) + await this.publish({ entries: next, failed }, now, state) return selected }) } @@ -150,7 +153,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !completed.has(failure.marker.sessionId)) }, now, - state.dismissedAt + state ) }) } @@ -194,7 +197,7 @@ export class AgentSessionRecoveryCapsule { ] }, now, - state.dismissedAt + state ) }) } @@ -225,7 +228,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !dismissed.has(failure.marker.sessionId)) }, now, - state.dismissedAt + state ) } return dismissed.size @@ -260,7 +263,7 @@ export class AgentSessionRecoveryCapsule { ) ) if (keptEntries.length !== entries.length || keptFailures.length !== failed.length) { - await this.publish({ entries: keptEntries, failed: keptFailures }, now, state.dismissedAt) + await this.publish({ entries: keptEntries, failed: keptFailures }, now, state) } }) } @@ -275,27 +278,23 @@ export class AgentSessionRecoveryCapsule { : entry ) // Normalizing on publish keeps a rolled-back retry a failure rather than a pending offer. - await this.publish({ entries: next, failed }, now, state.dismissedAt) + await this.publish({ entries: next, failed }, now, state) }) } - clearAll(now: number): Promise { + /** The explicit request to forget every record, or all but `keep`'s; see `dismissAll`. */ + clearAll(now: number, keep?: (marker: AgentSessionResumeMarker) => boolean): Promise { return withFileTransactionLock(this.filePath, async () => { - let entries: RecoveryEntry[] + let dismissal: ReturnType try { - entries = normalizeState(await this.readState(), now).entries + dismissal = dismissAll(await this.readState(), now, keep) } catch { - // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes - // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. - await this.publish({ entries: [], failed: [] }, now, now) + // Unreadable bytes hold no offer anyone sees; the dismissal must still land, fenced. + await this.publish({ entries: [], failed: [] }, now, { dismissedAt: now }) return 0 } - const pending = entries.filter((entry) => entry.state === 'pending') - // Dismiss is the explicit user request to forget every recovery record. An in-flight - // action may still finish, but its later complete/rollback becomes a no-op and cannot - // resurrect a row the user dismissed. - await this.publish({ entries: [], failed: [] }, now, now) - return pending.length + await this.publish(dismissal.records, now, dismissal.fence) + return dismissal.pendingCleared }) } @@ -321,16 +320,10 @@ export class AgentSessionRecoveryCapsule { private async publish( records: StoredRecords, now: number, - dismissedAt: number | undefined + fence: RecoveryCapsuleFence ): Promise { - const { entries, failed } = normalizeState(records, now) const { serialized } = stringifyJsonWithinByteLimit( - { - version: 2, - entries, - ...(dismissedAt === undefined ? {} : { dismissedAt }), - ...(failed.length === 0 ? {} : { failed }) - }, + storedCapsule(normalizeState(records, now), fence), MAX_CAPSULE_BYTES ) await removeStaleDurableWriteTempFiles(this.filePath, { 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 d088a13e44f3..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,8 +1,5 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - CLAUDE_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 { @@ -11,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 @@ -23,19 +23,9 @@ function clientCanRenderStructuredAgentSessionTab( tab: RuntimeMobileSessionAgentTab, clientCapabilities: readonly RuntimeCapability[] | undefined ): boolean { - if (!clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { - return false - } - if (tab.agent === 'codex') { - return true - } // 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 clientCapabilities.includes( - tab.agent === 'claude' - ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - : STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY - ) + return clientRendersStructuredAgent(clientCapabilities, tab.agent) } function resolveMobileStructuredChatFallbackTitle( 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 8088d2621896..479b67289c93 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,8 +1,9 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' @@ -23,18 +24,37 @@ export function supportsStructuredAgentSessions( ) } -/** Which agents' rows this client reads, or undefined when it reads every agent the host runs. - * Every client knows Claude and Codex; any other agent reaches only a client that renders the - * host's registered agents. */ +/** 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 this host `registered`; 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 + context: Pick, + registered: readonly { agent: string }[] ): ((agent: string) => boolean) | undefined { - return context.clientKind === undefined || - context.clientCapabilities?.includes( - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY - ) === true - ? undefined - : isAgentSessionHandleProvider + if (context.clientKind === undefined) { + return undefined + } + const reads = (agent: string) => clientRendersStructuredAgent(context.clientCapabilities, agent) + return registered.every(({ agent }) => reads(agent)) ? undefined : reads } /** 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..8c14b946ce8b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -0,0 +1,102 @@ +// "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches +// with: a paired runtime client without the registered-agents capability. A dismissal must stay +// final for every offer that client was shown, and leave an agent it cannot show 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, dismissedSessions } = JSON.parse( + await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + return { + ...(dismissedAt ? { dismissedAt } : {}), + ...(dismissedSessions ? { dismissedSessions } : {}) + } +} + +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('keeps a scoped dismiss-all final for what the desktop was shown when the host runs an agent it 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 } }) + await capsule.record([marker!], NOW) + + expect(await capsule.list(NOW)).toEqual([unreadable]) + expect(await storedFence(root)).toEqual({ dismissedSessions: { [marker!.sessionId]: NOW + 1 } }) +}) 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 index 88e32f076751..8101473f894b 100644 --- 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 @@ -9,6 +9,7 @@ import { 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, @@ -21,11 +22,16 @@ afterEach(clearStructuredHostStub) const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } -const OLD_CLIENT = STRUCTURED_CLIENT -const NEW_CLIENT = { +// 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: [ - ...STRUCTURED_CLIENT.clientCapabilities, + ...OLD_CLIENT.clientCapabilities, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY ] } @@ -63,7 +69,12 @@ function installRestartHost() { }) ) } - setStructuredAgentSessionHost(Object.assign(hostStub(), { restartResume })) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + restartResume, + agentDefinitions: () => [{ agent: 'claude' }, { agent: 'codex' }, { agent: 'grok' }] + }) + ) return { restartResume, offers: () => offers } } 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 0155ee04e53d..bb68f2b0dcad 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 @@ -21,7 +21,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - const audience = structuredAgentsReadBy(ctx) + const audience = structuredAgentsReadBy(ctx, host.agentDefinitions()) return { sessions: await host.restartResume.list(audience), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. @@ -38,7 +38,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) // Offers this client was never shown stay for a client that can show them. - const audience = structuredAgentsReadBy(ctx) + const audience = structuredAgentsReadBy(ctx, host.agentDefinitions()) const dismissed = await host.restartResume.dismiss(params.sessionIds, audience) if (params.sessionIds === undefined) { // The dismissal removed every pending and in-flight record this client sees, so a second @@ -64,7 +64,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ return host.restartResume.continueAfterRestart( params.sessionIds, structuredCallerFor(ctx).callerKey, - structuredAgentsReadBy(ctx) + structuredAgentsReadBy(ctx, host.agentDefinitions()) ) } }), From 212af519944a0ed00fda4d1e9bf1344ac0252ed0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:40:40 -0700 Subject: [PATCH 26/45] fix(agent-session): a changed agent definition never hides that agent's chats A record was readable only if every handle used the transport its agent's definition declares today, and its account variable was declared by some registered agent. A later build that drives the same agent over another protocol, or under a renamed variable, would have set every existing chat of that agent aside: no tab, no history, no error. Readability now asks only that the record agree with itself: its agent is registered, every handle shares one namespace owned by that agent, and the account variable is a well-formed name. Whether this build can drive it (the chain's transport is the one the agent speaks, and the account variable is that agent's own, since it becomes the child's environment) is decided in performAttach, the one admission every start of every agent passes through, and refused there as hostUnsupported. A record pinning another agent's variable is refused the same way rather than passing because some agent declares it. --- .../codex-structured-launch-resolution.ts | 2 +- ...-agent-session-acquisition-options.test.ts | 11 + ...ent-session-adapter-router-test-support.ts | 32 ++- ...tured-agent-session-adopted-import.test.ts | 1 + .../structured-agent-session-attach-flow.ts | 17 +- ...ured-agent-session-attach-orchestration.ts | 1 + ...ructured-agent-session-drivability.test.ts | 231 ++++++++++++++++++ ...ent-session-pre-spawn-first-answer.test.ts | 2 + ...nt-session-processless-reservation.test.ts | 7 + ...ructured-agent-session-provider-support.ts | 20 ++ src/shared/agent-session-account-home.test.ts | 35 +-- src/shared/agent-session-record.ts | 36 +-- src/shared/agent-session-stored-agent.test.ts | 21 +- src/shared/agent-session-stored-agent.ts | 16 -- 14 files changed, 368 insertions(+), 64 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 9802b18286df..18db5def05de 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -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/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 197585c42b59..64e42f3ebc9f 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, @@ -489,6 +498,7 @@ describe('structured session acquisition options', () => { fence: number | null ) => performAttach({ + agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, @@ -591,6 +601,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-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-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index d1b6cd280875..f03e2c1b655e 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, + agentDrivesSession +} 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,13 @@ 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) => + adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) && + (record === null || agentDrivesSession(input.agents, 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 +146,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-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts new file mode 100644 index 000000000000..db3d812de992 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -0,0 +1,231 @@ +// 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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' +import { agentSessionStoredAgents } from '../../../shared/agent-session-stored-agent' +import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../../shared/agent-session-stored-agent.test-fixture' +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' + +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 + } +} +const STORAGE = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) + +// 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 { + 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!, { agents: STORAGE })) + 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 } +} + +/** A Grok chat created while Grok spoke ACP and pinned GROK_HOME, its child since gone, read back + * by a build that registers Grok as `relaunched` declares it. */ +async function createdGrokChat(relaunched: StructuredAgentDefinition = GROK) { + 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, { + agents: agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), relaunched]) + }) + 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.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(changed) + 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() +}) 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..9efc08c0409f 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, @@ -316,6 +322,7 @@ describe('processless structured session reservation', () => { await expect( performAttach({ + agents: NO_STRUCTURED_AGENTS, ...input, authority: { ...input.authority, 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..8c5237994c83 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, @@ -39,3 +40,22 @@ export function adapterSupportsRecord( // A record stays readable unless the adapter explicitly rejects its location. return adapter.supportsLocation?.(record.location) ?? true } + +/** 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 + ) + ) +} diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 86e6e3bc1147..0c5c63f34f9b 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -3,10 +3,7 @@ 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' -import { - agentSessionStoredAgents, - isDeclaredAccountHomeVariable -} from './agent-session-stored-agent' +import { agentSessionStoredAgents } from './agent-session-stored-agent' import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' describe('agent session account home', () => { @@ -22,30 +19,20 @@ describe('agent session account home', () => { ).toBe('{"variable":"CODEX_HOME","path":"/home/dev/.codex"}') }) - it('admits only a variable a registered agent declares', () => { - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CLAUDE_CONFIG_DIR')).toBe( - true - ) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'CODEX_HOME')).toBe(true) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'PATH')).toBe(false) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, undefined)).toBe(false) - const withGrok = agentSessionStoredAgents([ - ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), - { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } - ]) - expect(isDeclaredAccountHomeVariable(withGrok, 'GROK_HOME')).toBe(true) - expect(isDeclaredAccountHomeVariable(CLAUDE_AND_CODEX_STORED_AGENTS, 'GROK_HOME')).toBe(false) - }) - - 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, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) - expect( + const withVariable = (variable: string) => isPersistedAgentSessionRecord( - { ...record, accountHome: { variable: 'LD_PRELOAD', path: '/tmp/x' } }, + { ...record, accountHome: { variable, path: '/tmp/x' } }, CLAUDE_AND_CODEX_STORED_AGENTS ) - ).toBe(false) + 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) + } }) it('refuses registering one agent twice', () => { diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index acf6ddc308e3..590658b9635e 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -26,10 +26,7 @@ import { } from './agent-session-provider-handle' import { isAgentSessionProviderHandleInNamespace } from './agent-session-provider-handle-encoding' import type { AgentSessionAccountHome } from './agent-session-account-home' -import { - isDeclaredAccountHomeVariable, - type AgentSessionStoredAgents -} from './agent-session-stored-agent' +import type { AgentSessionStoredAgents } from './agent-session-stored-agent' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -227,16 +224,18 @@ export function isAgentSessionProcessIdentity( ) } -function isAgentSessionAccountHome( - value: unknown, - agents: AgentSessionStoredAgents -): value is AgentSessionAccountHome { +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 + * (`agentDrivesRecord`), 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 ( - isDeclaredAccountHomeVariable(agents, home.variable) && + typeof home.variable === 'string' && + ENVIRONMENT_VARIABLE_NAME.test(home.variable) && isBoundedString(home.path, MAX_PATH_LENGTH) ) } @@ -339,7 +338,9 @@ 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. - * `agents` are the host's registered agents: a record of any other agent is not readable here. */ + * `agents` are the host's registered agents: a record of any other agent is not readable here. A + * registered agent's record is readable when it agrees with itself; whether this build can drive + * its transport and account variable is decided only when its agent would start. */ export function isPersistedAgentSessionRecord( value: unknown, agents: AgentSessionStoredAgents @@ -348,13 +349,13 @@ export function isPersistedAgentSessionRecord( return false } const record = value as Partial - const agent = typeof record.provider === 'string' ? agents.get(record.provider) : undefined const fieldsValid = record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && - agent !== undefined && - isAgentSessionAccountHome(record.accountHome, agents) && + typeof record.provider === 'string' && + agents.has(record.provider) && + isAgentSessionAccountHome(record.accountHome) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && (record.conversationCommand === undefined || @@ -374,10 +375,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) - const namespace = { transport: agent.handleTransport, agent: agent.agent } + // 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) => isAgentSessionProviderHandleInNamespace(link.handle, namespace)) && + 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-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts index 5b49337673c0..77fa3f84c863 100644 --- a/src/shared/agent-session-stored-agent.test.ts +++ b/src/shared/agent-session-stored-agent.test.ts @@ -43,10 +43,27 @@ describe('records of registered agents', () => { ).toBe(false) }) - it("refuses handles outside the agent's declared transport", () => { + // 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')), WITH_GROK) - ).toBe(false) + ).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 }), + WITH_GROK + ) + ).toBe(false) + } }) it('keeps reading Claude and Codex records exactly as before', () => { diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts index b013e2afacb1..79c90f245e79 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -32,19 +32,3 @@ export function agentSessionStoredAgents( } return byAgent } - -/** Admits only a variable a registered agent declares: a record's variable becomes a child's environment. */ -export function isDeclaredAccountHomeVariable( - agents: AgentSessionStoredAgents, - value: unknown -): value is string { - if (typeof value !== 'string') { - return false - } - for (const agent of agents.values()) { - if (agent.accountHomeVariable === value) { - return true - } - } - return false -} From e23946fe3a5a5e5ce73bdb427e93253ac0361b4b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:44:29 -0700 Subject: [PATCH 27/45] refactor(agent-session): each agent's registration says where it runs and which account it pins createSupport, create and the model catalog's account read still chose a location rule and an account resolver by name (Claude, Codex, else none), so a new agent would have needed a third branch beside the registration list that already decides storage, routing and publication. Each runtime registration now carries supportsLocation(location) and resolveAccountHomePath({ launchEnv, location, purpose, workspacePath }), and Claude's and Codex's rules move into their entries unchanged: a read still syncs no home and starts no bridge, a Codex launch still trusts the workspace first, and WSL locations resolve as before. The list exists before the host is built, so none of these reads installs the host, and an agent the list does not hold is answered no without opening the journal. --- .../structured-agent-session-host.ts | 4 +- ...structured-agent-session-create-support.ts | 89 +++++++----------- ...structured-agent-registration-seam.test.ts | 87 ++++++++++++++++++ .../structured-agent-runtime-registrations.ts | 90 ++++++++++++++++++- 4 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts 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 1ccdb2188d2b..48a4e0d2bbc1 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 @@ -216,9 +216,7 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) - /** What this runtime registered: one agent's definition (null when unregistered), or all of them. */ - agentDefinition = (agent: string): StructuredAgentDefinition | null => - this.deps.agents.definition(agent) + /** Every agent this runtime registered: what `agentSession.agents` publishes. */ agentDefinitions = (): readonly StructuredAgentDefinition[] => this.deps.agents.definitions() private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( 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 d3bf0e191343..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,17 +11,13 @@ 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 @@ -48,65 +42,48 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) } - /** Claude and Codex answer from their own location rules without installing the host, as they - * always have; any other agent is the answer of the adapter the host registered for it. */ + /** 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) { - if (agent === 'claude') { - return supportsClaudeStructuredLocation(location) - } - if (agent === 'codex') { - return supportsCodexStructuredLocation(location) - } - await ensureStructuredAgentSessionHostUnlessRefused(() => - this.ensureStructuredAgentSessionHost() - ) - return getStructuredAgentSessionHost()?.supportsCreate(location, agent) ?? false + return structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false } - /** Where a launch of `agent` finds its account, resolved on this host; null for an agent with no - * resolver here, whose create is refused. Claude and Codex are the agents with one today. */ + /** 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' ) { - if (agent === 'claude') { - return async ({ launchEnv, location }) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location?.wslDistro ?? null, - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target) - }) + const registration = structuredAgentRuntimeRegistration(agent) + if (!registration) { + return null } - if (agent === 'codex') { - if (purpose === 'read') { - // Read-only resolver, never launch prep: a picker mount or discovery read - // must not sync homes, start bridges, or clear an account selection. - return async ({ launchEnv }) => - resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn - }) - } - return async ({ launchEnv }) => { - await applyStructuredCodexWorkspaceTrust({ - workspacePath: (await this.resolveRuntimeFileTarget(worktree)).worktree.path, - launchEnv, - settings: this.requireStore().getSettings() - }) - return resolveStructuredCodexAccountHomePath({ - launchEnv, - resolveLaunchHome: this.prepareCodexStructuredLaunchFn - }) - } + const services = { + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), + prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, + readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, + workspaceTrustSettings: () => this.requireStore().getSettings() } - return null + 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 the installed host registered for `agent`: what its account home pins. */ - protected async requireRegisteredStructuredAgent(agent: StructuredAgentId) { - await this.ensureStructuredAgentSessionHost() - const definition = getStructuredAgentSessionHost()?.agentDefinition(agent) ?? null + /** 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' @@ -197,7 +174,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR reason: 'hostUnsupported' }) } - const definition = await this.requireRegisteredStructuredAgent(agent) + const definition = this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv @@ -235,7 +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 = await this.requireRegisteredStructuredAgent(input.agent) + const definition = this.requireRegisteredStructuredAgent(input.agent) const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, 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..400ead611f36 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -0,0 +1,87 @@ +// 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 { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + STRUCTURED_AGENT_STORAGE, + 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('is what the record store admits', () => { + expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + }) + + 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/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index bb183d65ba78..eba577611b3e 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -1,9 +1,14 @@ // The structured agents this runtime registers, in one list. Each entry's definition decides which -// agents' records the store admits, and its factory builds the adapter the router drives that agent -// with once the store is open, so storage and routing cannot disagree about which agents exist. +// agents' records the store admits, its factory builds the adapter the router drives that agent with +// once the store is open, and its location rule and account resolver answer the picker and create +// before any host exists, so none of them can disagree about which agents exist or where they run. // Adding an agent is one more entry. 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' @@ -24,6 +29,12 @@ import type { createStructuredAgentSessionDispatchFollowUps } from './structured 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 = { @@ -43,9 +54,36 @@ export type StructuredAgentRuntimeAdapter = StructuredAgentSessionAdapter & { 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 { @@ -106,12 +144,56 @@ function createClaudeAdapter( }) } +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 }, - { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter } + { + 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 + ) +} + /** What the record store admits: exactly the registered agents' declared storage. */ export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) From a83313be89dc12743101908524fce85c8a6de11d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:47:10 -0700 Subject: [PATCH 28/45] test(agent-session): drop a duplicate registry key; keep fences out of the capsule state A spread input already carries the registry, so the explicit key was overwritten (TS2783). The session-fence helper now returns only the fence, not the capsule state it was given. --- ...structured-agent-session-processless-reservation.test.ts | 1 - src/main/runtime/agent-session-recovery-capsule-entries.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 9efc08c0409f..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 @@ -322,7 +322,6 @@ describe('processless structured session reservation', () => { await expect( performAttach({ - agents: NO_STRUCTURED_AGENTS, ...input, authority: { ...input.authority, diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index 2d7bfa4b24e5..e1c1a562ad61 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -111,13 +111,13 @@ function withSessionFences( for (const sessionId of sessionIds) { dismissedSessions.set(sessionId, Math.max(at, dismissedSessions.get(sessionId) ?? at)) } - return { ...fence, dismissedSessions } + return { dismissedAt: fence.dismissedAt, dismissedSessions } } /** A dismiss-all of `state`: every record but those `keep` answers true for goes, and stays gone. * An in-flight action's later complete/rollback becomes a no-op, and a late teardown writer is - * fenced: for every session, or with `keep` for each cleared one, so a session kept for a client - * that sees it is never fenced by one that does not. */ + * fenced: for every session, or with `keep` for each cleared one, so a dismissal never fences a + * chat its client was not shown. */ export function dismissAll( state: RecoveryCapsuleState, now: number, From b557e5e4f18ed76dd49266e5174309c136c53319 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:10:23 -0700 Subject: [PATCH 29/45] fix(agent-session): a scoped dismiss-all persists no per-session fence The per-session dismissal fence added a forever-persisted capsule field with an arbitrary cap that served no caller: only the local desktop calls the restart methods, and on a host whose agents it all shows it gets no audience and takes the unscoped, fenced dismissal. A scoped dismiss-all (a caller that cannot show some registered agent) keeps that agent's offers, clears only its audience's unwritten witnesses, and writes no fence; a late write from this process is serialized behind it. The field never left this unreleased branch. --- ...red-agent-session-restart-audience.test.ts | 3 +- ...ed-agent-session-restart-failure-ledger.ts | 29 ++--- .../agent-session-recovery-capsule-entries.ts | 102 +----------------- .../agent-session-recovery-capsule.test.ts | 53 --------- .../runtime/agent-session-recovery-capsule.ts | 51 +++++---- ...gent-session-restart-dismiss-fence.test.ts | 18 ++-- 6 files changed, 56 insertions(+), 200 deletions(-) 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 index 909e7583e08c..f468d0bd12ee 100644 --- 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 @@ -74,7 +74,6 @@ it('keeps a failure the caller cannot show, and leaves it out of an action reply expect(await host.restartResume.dismiss(undefined, cannotShowCodex)).toBe(0) expect(await host.restartResume.listFailures()).toHaveLength(1) - // A dismiss-all counts pending offers, as it always has; the failure goes with it all the same. - expect(await host.restartResume.dismiss(undefined, showsCodex)).toBe(0) + 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-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 8c986463e82b..8697fdfb552a 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 @@ -59,8 +59,8 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes and stays - * gone. With an audience, only records of agents it sees go, and only they are fenced. */ + /** Named sessions forget their offer or failure; unnamed, every durable record goes. With an + * audience, only records of agents it sees go, and an unnamed dismissal is no fence. */ dismiss: ( sessionIds: readonly string[] | undefined, beforeClearAll: (audience?: StructuredAgentSessionRestartAudience) => void, @@ -216,20 +216,25 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { settle, dismiss: (sessionIds, beforeClearAll, audience) => deps.enqueue(async () => { - // Decided under the capsule lock. A record whose chat this host cannot read names no - // agent the audience was shown, so it stays. - const hidden = (marker: AgentSessionResumeMarker) => { - if (!audience) { - return false + 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. + const hidden = (marker: AgentSessionResumeMarker) => { + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) } - const record = deps.getRecord(marker.sessionId) - return record === null || !audience(record.provider) + if (sessionIds === undefined) { + 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(), hidden)) ?? 0 + return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } - beforeClearAll(audience) - return (await deps.capsule?.clearAll(deps.now(), audience ? hidden : undefined)) ?? 0 + beforeClearAll() + return (await deps.capsule?.clearAll(deps.now())) ?? 0 }) } } diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index e1c1a562ad61..a5d1d899723a 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -42,12 +42,8 @@ const capsuleSchema = z.object({ version: z.literal(2), entries: z.array(z.unknown()), dismissedAt: z.number().int().nonnegative().optional(), - dismissedSessions: z.unknown().optional(), failed: z.unknown().optional() }) -const sessionFencesSchema = z.record(z.string().min(1), z.number().int().nonnegative()) -/** Newest per-session fences kept; a dismiss-all fence supersedes every older one. */ -const MAX_SESSION_FENCES = 512 /** What an acted-on offer left behind when the agent did not carry on. Current only while nothing * newer happened in that chat; also dies with a dismissal, a successful retry, or a newer @@ -83,101 +79,7 @@ export type RecoveryEntry = { export type RecoveryCapsuleState = { entries: RecoveryEntry[] failed: AgentSessionResumeFailureRecord[] -} & RecoveryCapsuleFence - -/** What keeps a dismissed offer dismissed: a teardown witness recorded at or before the fence is - * dropped. `dismissedAt` fences every session (dismiss-all); `dismissedSessions` fences single - * sessions, for a dismiss-all scoped to the agents one client sees. */ -export type RecoveryCapsuleFence = { - dismissedAt?: number - dismissedSessions?: ReadonlyMap -} - -export function isFencedOut( - fence: RecoveryCapsuleFence, - marker: AgentSessionResumeMarker -): boolean { - const at = Math.max(fence.dismissedAt ?? -1, fence.dismissedSessions?.get(marker.sessionId) ?? -1) - return marker.recordedAt <= at -} - -/** `fence` with each of `sessionIds` fenced at `at`. */ -function withSessionFences( - fence: RecoveryCapsuleFence, - sessionIds: Iterable, - at: number -): RecoveryCapsuleFence { - const dismissedSessions = new Map(fence.dismissedSessions) - for (const sessionId of sessionIds) { - dismissedSessions.set(sessionId, Math.max(at, dismissedSessions.get(sessionId) ?? at)) - } - return { dismissedAt: fence.dismissedAt, dismissedSessions } -} - -/** A dismiss-all of `state`: every record but those `keep` answers true for goes, and stays gone. - * An in-flight action's later complete/rollback becomes a no-op, and a late teardown writer is - * fenced: for every session, or with `keep` for each cleared one, so a dismissal never fences a - * chat its client was not shown. */ -export function dismissAll( - state: RecoveryCapsuleState, - now: number, - keep?: (marker: AgentSessionResumeMarker) => boolean -): { - records: Pick - fence: RecoveryCapsuleFence - pendingCleared: number -} { - const { entries, failed } = normalizeState(state, now) - const kept = (record: { marker: AgentSessionResumeMarker }) => keep?.(record.marker) === true - const cleared = [...entries, ...failed].filter((record) => !kept(record)) - return { - records: { entries: entries.filter(kept), failed: failed.filter(kept) }, - fence: keep - ? withSessionFences( - state, - cleared.map((record) => record.marker.sessionId), - now - ) - : { dismissedAt: now }, - pendingCleared: entries.filter((entry) => entry.state === 'pending' && !kept(entry)).length - } -} - -/** The capsule file's contents. */ -export function storedCapsule( - { entries, failed }: Pick, - fence: RecoveryCapsuleFence -) { - return { - version: 2, - entries, - ...storedFence(fence), - ...(failed.length === 0 ? {} : { failed }) - } -} - -/** The fence as stored: a session fence the dismiss-all fence covers is dropped, and only the - * newest are kept, so the set cannot grow without bound. */ -function storedFence(fence: RecoveryCapsuleFence): { dismissedAt?: number - dismissedSessions?: Record -} { - const sessions = [...(fence.dismissedSessions ?? new Map())] - .filter(([, at]) => fence.dismissedAt === undefined || at > fence.dismissedAt) - .sort(([, left], [, right]) => right - left) - .slice(0, MAX_SESSION_FENCES) - return { - ...(fence.dismissedAt === undefined ? {} : { dismissedAt: fence.dismissedAt }), - ...(sessions.length === 0 ? {} : { dismissedSessions: Object.fromEntries(sessions) }) - } -} - -// Advisory like failures: an unreadable set reads as none rather than costing every offer. -function parseSessionFences(value: unknown): ReadonlyMap | undefined { - const parsed = sessionFencesSchema.safeParse(value) - return parsed.success && Object.keys(parsed.data).length > 0 - ? new Map(Object.entries(parsed.data)) - : undefined } function parseMarker(value: unknown): AgentSessionResumeMarker { @@ -242,12 +144,10 @@ export function parseState(raw: string): RecoveryCapsuleState { } } const capsule = capsuleSchema.parse(value) - const dismissedSessions = parseSessionFences(capsule.dismissedSessions) return { entries: capsule.entries.map(parseEntry), failed: parseFailures(capsule.failed), - ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }), - ...(dismissedSessions ? { dismissedSessions } : {}) + ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }) } } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 8d617f115534..bb3c93a6a989 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -516,59 +516,6 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW + 2)).toEqual([newer]) }) - it('fences each session a scoped dismiss-all clears, and leaves what it keeps unfenced', async () => { - const hidden = marker({ sessionId: 'hidden' }) - await capsule.record([marker(), hidden], NOW) - await capsule.beginResume([SESSION], 'operation-a', NOW) - - expect(await capsule.clearAll(NOW + 1, (kept) => kept.sessionId === 'hidden')).toBe(0) - expect(await capsule.list(NOW + 1)).toEqual([hidden]) - await capsule.rollbackResume('operation-a', NOW + 1) - // The stale teardown write of the cleared chat stays out; the kept chat still takes a newer one. - const newerHidden = marker({ sessionId: 'hidden', recordedAt: NOW + 1, teardownId: 'later' }) - await capsule.record([marker(), newerHidden], NOW + 1) - expect(await capsule.list(NOW + 1)).toEqual([newerHidden]) - - const newer = marker({ recordedAt: NOW + 2, teardownId: 'teardown-new' }) - await capsule.record([newer], NOW + 2) - expect(await capsule.list(NOW + 2)).toContainEqual(newer) - await capsule.record([marker()], NOW + 2) - expect(await capsule.list(NOW + 2)).toContainEqual(newer) - }) - - it('drops session fences a dismiss-all fence covers, and keeps the newest of the rest', async () => { - await capsule.record([marker()], NOW) - await capsule.clearAll(NOW, () => false) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ - dismissedSessions: { [SESSION]: NOW } - }) - - await capsule.clearAll(NOW + 1) - expect(JSON.parse(await readFile(filePath, 'utf8'))).not.toHaveProperty('dismissedSessions') - - const many = Array.from({ length: 600 }, (_, index) => - marker({ sessionId: `session-${index}`, recordedAt: NOW + 2 + index }) - ) - await capsule.record(many, NOW + 2) - for (const [index, each] of many.entries()) { - await capsule.clearAll(NOW + 2 + index, (kept) => kept.sessionId !== each.sessionId) - } - const fences = JSON.parse(await readFile(filePath, 'utf8')).dismissedSessions - expect(Object.keys(fences)).toHaveLength(512) - expect(fences).toHaveProperty('session-599') - expect(fences).not.toHaveProperty('session-0') - }) - - it('lets a scoped dismiss-all replace an unreadable file with an empty fence', async () => { - await writeFile(filePath, '{') - - expect(await capsule.clearAll(NOW, () => true)).toBe(0) - expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ - entries: [], - dismissedAt: NOW - }) - }) - it('keeps a legacy v1 file readable until a mutating operation migrates it', async () => { const legacy = JSON.stringify({ version: 1, markers: [marker()] }) await writeFile(filePath, legacy) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index ee7002558e58..7343ab64e8e1 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -11,14 +11,10 @@ import { } from '../durable-file-write' import { withFileTransactionLock } from '../file-transaction-lock' import { - dismissAll, - isFencedOut, MAX_FAILURE_FIELD_LENGTH, normalizeState, parseState, shouldReplaceMarker, - storedCapsule, - type RecoveryCapsuleFence, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, type RecoveryCapsuleState, @@ -68,8 +64,9 @@ export class AgentSessionRecoveryCapsule { const { entries, failed } = normalizeState(state, now) const bySession = new Map(entries.map((entry) => [entry.marker.sessionId, entry])) const failedBySession = new Map(failed.map((failure) => [failure.marker.sessionId, failure])) + const dismissedAt = state.dismissedAt for (const marker of markers) { - if (isFencedOut(state, marker)) { + if (dismissedAt !== undefined && marker.recordedAt <= dismissedAt) { continue } const existing = bySession.get(marker.sessionId) @@ -95,7 +92,7 @@ export class AgentSessionRecoveryCapsule { } // Keep the fence after a newer interruption. It still admits genuinely newer markers, // while an older delayed writer remains unable to resurrect a dismissed chat later. - await this.publish({ entries: [...bySession.values()], failed }, now, state) + await this.publish({ entries: [...bySession.values()], failed }, now, dismissedAt) }) } @@ -128,7 +125,7 @@ export class AgentSessionRecoveryCapsule { next.push(reserve(failure.marker)) } } - await this.publish({ entries: next, failed }, now, state) + await this.publish({ entries: next, failed }, now, state.dismissedAt) return selected }) } @@ -153,7 +150,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !completed.has(failure.marker.sessionId)) }, now, - state + state.dismissedAt ) }) } @@ -197,7 +194,7 @@ export class AgentSessionRecoveryCapsule { ] }, now, - state + state.dismissedAt ) }) } @@ -228,7 +225,7 @@ export class AgentSessionRecoveryCapsule { failed: failed.filter((failure) => !dismissed.has(failure.marker.sessionId)) }, now, - state + state.dismissedAt ) } return dismissed.size @@ -263,7 +260,7 @@ export class AgentSessionRecoveryCapsule { ) ) if (keptEntries.length !== entries.length || keptFailures.length !== failed.length) { - await this.publish({ entries: keptEntries, failed: keptFailures }, now, state) + await this.publish({ entries: keptEntries, failed: keptFailures }, now, state.dismissedAt) } }) } @@ -278,23 +275,27 @@ export class AgentSessionRecoveryCapsule { : entry ) // Normalizing on publish keeps a rolled-back retry a failure rather than a pending offer. - await this.publish({ entries: next, failed }, now, state) + await this.publish({ entries: next, failed }, now, state.dismissedAt) }) } - /** The explicit request to forget every record, or all but `keep`'s; see `dismissAll`. */ - clearAll(now: number, keep?: (marker: AgentSessionResumeMarker) => boolean): Promise { + clearAll(now: number): Promise { return withFileTransactionLock(this.filePath, async () => { - let dismissal: ReturnType + let entries: RecoveryEntry[] try { - dismissal = dismissAll(await this.readState(), now, keep) + entries = normalizeState(await this.readState(), now).entries } catch { - // Unreadable bytes hold no offer anyone sees; the dismissal must still land, fenced. - await this.publish({ entries: [], failed: [] }, now, { dismissedAt: now }) + // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes + // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. + await this.publish({ entries: [], failed: [] }, now, now) return 0 } - await this.publish(dismissal.records, now, dismissal.fence) - return dismissal.pendingCleared + const pending = entries.filter((entry) => entry.state === 'pending') + // Dismiss is the explicit user request to forget every recovery record. An in-flight + // action may still finish, but its later complete/rollback becomes a no-op and cannot + // resurrect a row the user dismissed. + await this.publish({ entries: [], failed: [] }, now, now) + return pending.length }) } @@ -320,10 +321,16 @@ export class AgentSessionRecoveryCapsule { private async publish( records: StoredRecords, now: number, - fence: RecoveryCapsuleFence + dismissedAt: number | undefined ): Promise { + const { entries, failed } = normalizeState(records, now) const { serialized } = stringifyJsonWithinByteLimit( - storedCapsule(normalizeState(records, now), fence), + { + version: 2, + entries, + ...(dismissedAt === undefined ? {} : { dismissedAt }), + ...(failed.length === 0 ? {} : { failed }) + }, MAX_CAPSULE_BYTES ) await removeStaleDurableWriteTempFiles(this.filePath, { 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 index 8c14b946ce8b..9937a3eb3d89 100644 --- 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 @@ -1,6 +1,7 @@ // "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches -// with: a paired runtime client without the registered-agents capability. A dismissal must stay -// final for every offer that client was shown, and leave an agent it cannot show alone. +// 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' @@ -50,13 +51,10 @@ function withPilot(): StructuredAgentRegistry { } async function storedFence(root: string) { - const { dismissedAt, dismissedSessions } = JSON.parse( + const { dismissedAt } = JSON.parse( await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) - return { - ...(dismissedAt ? { dismissedAt } : {}), - ...(dismissedSessions ? { dismissedSessions } : {}) - } + return dismissedAt ? { dismissedAt } : {} } async function dismissAllFromDesktop() { @@ -81,7 +79,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) }) -it('keeps a scoped dismiss-all final for what the desktop was shown when the host runs an agent it cannot show', async () => { +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, @@ -95,8 +93,8 @@ it('keeps a scoped dismiss-all final for what the desktop was shown when the hos await capsule.record([unreadable], NOW) expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) - await capsule.record([marker!], NOW) expect(await capsule.list(NOW)).toEqual([unreadable]) - expect(await storedFence(root)).toEqual({ dismissedSessions: { [marker!.sessionId]: NOW + 1 } }) + // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. + expect(await storedFence(root)).toEqual({}) }) From f0e695f83ebf79103b5852fc779d51d479d99473 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 30/45] fix(agent-session): refuse an attach whose agent is not the session's own The start check judges the record's agent while the router starts the adapter of params.agent, and the attach wire let the two differ. Admission now refuses agent !== provider as requestMalformed, so the agent checked is the agent started. Every host-built attach already sets them equal. --- .../structured-agent-session-attach.ts | 11 ++++++ ...ructured-agent-session-drivability.test.ts | 35 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) 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 f31e48c1eef3..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 @@ -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-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index db3d812de992..1fba19a4a1aa 100644 --- 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 @@ -9,7 +9,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' import { agentSessionStoredAgents } from '../../../shared/agent-session-stored-agent' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../../shared/agent-session-stored-agent.test-fixture' 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' @@ -54,7 +53,7 @@ const GROK: StructuredAgentDefinition = { effortDefaultsToModel: false } } -const STORAGE = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) +const STORAGE = agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, GROK]) // 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 @@ -163,7 +162,7 @@ async function createdGrokChat(relaunched: StructuredAgentDefinition = GROK) { }) expect(created.result).toMatchObject({ ok: true }) const store = await openTestAgentSessionRecordStore(root, { - agents: agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), relaunched]) + agents: agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, relaunched]) }) await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } @@ -229,3 +228,33 @@ it('starts the same chat again once its agent is declared as it was', async () = 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() +}) From 7b52fb5c1c6757edeeb8a17bfbda7ed9dabe5e87 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 31/45] fix(agent-session): offer to start a chat only when the start would accept it The restart offer, a failure's retryable flag and the pre-send check asked only whether the adapter runs the chat's location, while the start also refuses a record this build cannot drive; such a chat was offered Resume and its Retry failed forever. One host predicate, hostCanStartRecord (location support and agentDrivesSession), now answers all four; adapterSupportsRecord stays the reader gate, and an undrivable chat's offer is kept, not retired. --- .../structured-agent-session-agent-start.ts | 4 +- .../structured-agent-session-attach-flow.ts | 7 +- ...ructured-agent-session-provider-support.ts | 13 ++++ ...ctured-agent-session-restart-candidates.ts | 8 ++- ...tured-agent-session-restart-resume-host.ts | 16 ++--- ...d-agent-session-restart-undrivable.test.ts | 72 +++++++++++++++++++ 6 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts 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 d2ef237aa1b6..2c718ba3fc23 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 { finishOwedStructuredAgentSessionWindDownUnderSerialize, type StructuredAgentSessionLifetimeContext @@ -166,7 +166,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 f03e2c1b655e..fc6bb15a1f98 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 @@ -31,7 +31,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { adapterSupportsCreateIfDeclared, - agentDrivesSession + hostCanStartRecord } from './structured-agent-session-provider-support' import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -106,8 +106,9 @@ export async function performAttach( // 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) => - adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) && - (record === null || agentDrivesSession(input.agents, record)) + record === null + ? adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) + : hostCanStartRecord(input, record) // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. if (!supported(store.getRecord(sessionId))) { return unsupported() 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 8c5237994c83..c6db59498ea1 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 @@ -59,3 +59,16 @@ export function agentDrivesSession( ) ) } + +/** 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 chat asks only `adapterSupportsRecord`. */ +export function hostCanStartRecord( + deps: { + adapter: StructuredAgentSessionAdapter + agents: Pick + }, + record: AgentSessionRecord +): boolean { + return adapterSupportsRecord(deps.adapter, record) && agentDrivesSession(deps.agents, record) +} 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 b16f76c1633b..29b4e62b431e 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,14 +2,15 @@ // // 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, and all get the same answer. +// 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 { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' 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 @@ -28,6 +29,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 }): StructuredAgentSessionRestartCandidateReader { @@ -35,7 +37,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, - supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + supportsRecord: (record) => hostCanStartRecord(deps, record), movedOn: deps.movedOn, latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt( 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 ac91a00f8723..62b981971899 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 { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' import { continuationFailureOutcome, @@ -46,7 +43,7 @@ import { import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -86,12 +83,10 @@ export type StructuredAgentSessionRestartResume = { } export function createStructuredAgentSessionRestartResume( - deps: { - store: AgentSessionRecordStore - adapter: StructuredAgentSessionAdapter - recoveryCapsule?: AgentSessionRecoveryCapsule - logger: StructuredAgentSessionLogger - }, + deps: Pick< + StructuredAgentSessionHostDeps, + 'store' | 'adapter' | 'recoveryCapsule' | 'logger' | 'agents' + >, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces ): StructuredAgentSessionRestartResume { @@ -118,6 +113,7 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, adapter: deps.adapter, + agents: deps.agents, movedOn: withdrawal.movedOn }) const failures = createStructuredAgentSessionRestartFailureLedger({ 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 } + ]) +}) From 38d510dbbc23bb9649bad3749f2897447c2e1aa7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 32/45] fix(agent-session): the model-catalog probe uses a record's account only if this build drives it A record-scoped catalog read started the agent's lister under the record's account path whatever variable the record pinned. It now uses that path only when agentDrivesSession holds, and otherwise resolves the account as for a read with no record. --- ...-structured-effort-default-at-rest.test.ts | 1 + .../agent-model-catalog-service.test.ts | 30 +++++++++++++++++++ .../agent-model-catalog-service.ts | 6 +++- ...uctured-agent-session-options-read.test.ts | 1 + .../structured-agent-model-catalog-wiring.ts | 4 +++ .../structured-agent-session-runtime.ts | 2 +- 6 files changed, 42 insertions(+), 2 deletions(-) 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/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 020254851513..84a2649bee23 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 } }) @@ -236,6 +263,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({ @@ -252,6 +280,7 @@ describe('agent model catalog service', () => { const service = createAgentModelCatalogService({ store, getRecord: () => undefined, + drivesRecord: () => true, resolveAccountHome: async () => CODEX_HOME('/homes/selected'), probes: { codex: probe } }) @@ -269,6 +298,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 176f3fcb59c4..4942ad68fd62 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 @@ -16,6 +16,9 @@ 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. */ @@ -92,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-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/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-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b7cb095c63b8..f409d5495eb7 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -293,7 +293,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 { From 1a569b82f7795967daa55832e747bff010fe0654 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:17:28 -0700 Subject: [PATCH 33/45] refactor(agent-session): the record store admits agent ids; comments say where transport is checked The store only ever asked whether an agent is registered, so its admission list is now the registered ids; the definition is the one home of an agent's transport and account variable. Comments that said the record store checks transport, and one that cited a nonexistent function, now point at agentDrivesSession and the attach admission. The isPersistedAgentSessionRecord(value, agents) call shape and the test fixture the cross-version probe imports are kept. --- .../claude-structured-launch-resolution.ts | 2 +- ...tructured-agent-stored-definitions.test.ts | 22 +++++++++------ src/shared/agent-session-account-home.test.ts | 13 ++------- .../agent-session-provider-handle-encoding.ts | 7 +++-- src/shared/agent-session-record.ts | 2 +- ...agent-session-stored-agent.test-fixture.ts | 18 ++---------- src/shared/agent-session-stored-agent.test.ts | 2 +- src/shared/agent-session-stored-agent.ts | 28 +++++++++++-------- 8 files changed, 42 insertions(+), 52 deletions(-) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 6fea15d79fb7..e20c1bd8b8ee 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -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/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index c3e7fe0927d1..31510e5cb6a5 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -2,21 +2,25 @@ 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_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' +import { + CLAUDE_STRUCTURED_HANDLE_NAMESPACE, + CODEX_STRUCTURED_HANDLE_NAMESPACE +} from '../../shared/agent-session-provider-handle-encoding' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, STRUCTURED_AGENT_STORAGE } from './structured-agent-runtime-registrations' describe('what the shipped definitions let a record store', () => { - it('matches the storage every older build wrote', () => { - for (const definition of [CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) { - const { agent, handleTransport, accountHomeVariable } = definition - expect({ agent, handleTransport, accountHomeVariable }).toEqual( - CLAUDE_AND_CODEX_STORED_AGENTS.get(agent) - ) - } - expect(CLAUDE_STRUCTURED_AGENT.accountHomeVariable).toBe('CLAUDE_CONFIG_DIR') - expect(CODEX_STRUCTURED_AGENT.accountHomeVariable).toBe('CODEX_HOME') + 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' + }) }) it('admits exactly the agents the runtime routes, stored as before', () => { diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 0c5c63f34f9b..a59ad59f43a7 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -7,14 +7,10 @@ import { agentSessionStoredAgents } from './agent-session-stored-agent' import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' describe('agent session account home', () => { - it('pins the variables older builds wrote and read', () => { - expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('claude')?.accountHomeVariable).toBe( - 'CLAUDE_CONFIG_DIR' - ) - expect(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')?.accountHomeVariable).toBe('CODEX_HOME') + it('stores the variable and path exactly as older builds wrote them', () => { expect( JSON.stringify( - agentSessionAccountHome(CLAUDE_AND_CODEX_STORED_AGENTS.get('codex')!, '/home/dev/.codex') + agentSessionAccountHome({ accountHomeVariable: 'CODEX_HOME' }, '/home/dev/.codex') ) ).toBe('{"variable":"CODEX_HOME","path":"/home/dev/.codex"}') }) @@ -37,10 +33,7 @@ describe('agent session account home', () => { it('refuses registering one agent twice', () => { expect(() => - agentSessionStoredAgents([ - ...CLAUDE_AND_CODEX_STORED_AGENTS.values(), - { agent: 'codex', handleTransport: 'acp', accountHomeVariable: 'CODEX_HOME' } - ]) + agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, { agent: 'codex' }]) ).toThrow('registered twice') }) }) diff --git a/src/shared/agent-session-provider-handle-encoding.ts b/src/shared/agent-session-provider-handle-encoding.ts index cbaf87853544..89d5a496fbe1 100644 --- a/src/shared/agent-session-provider-handle-encoding.ts +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -49,9 +49,10 @@ export function isAgentSessionProviderHandleInNamespace( /** * 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 transport is - * its registered definition's, which the record store checks on every row it loads or writes; a - * chain never changes transport after its first link. + * 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, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 590658b9635e..6750ec306454 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -227,7 +227,7 @@ 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 - * (`agentDrivesRecord`), so an agent that renames its variable never hides its chats. */ + * (`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 diff --git a/src/shared/agent-session-stored-agent.test-fixture.ts b/src/shared/agent-session-stored-agent.test-fixture.ts index de441fff4ad1..ca3d41f567cb 100644 --- a/src/shared/agent-session-stored-agent.test-fixture.ts +++ b/src/shared/agent-session-stored-agent.test-fixture.ts @@ -1,19 +1,7 @@ -import { - CLAUDE_STRUCTURED_HANDLE_NAMESPACE, - CODEX_STRUCTURED_HANDLE_NAMESPACE -} from './agent-session-provider-handle-encoding' import { agentSessionStoredAgents } from './agent-session-stored-agent' -/** What the shipped Claude and Codex definitions declare; a runtime test pins them equal. */ +/** The agents every older build's record store admitted; a runtime test pins the shipped list to it. */ export const CLAUDE_AND_CODEX_STORED_AGENTS = agentSessionStoredAgents([ - { - agent: 'claude', - handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, - accountHomeVariable: 'CLAUDE_CONFIG_DIR' - }, - { - agent: 'codex', - handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, - accountHomeVariable: 'CODEX_HOME' - } + { agent: 'claude' }, + { agent: 'codex' } ]) diff --git a/src/shared/agent-session-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts index 77fa3f84c863..611efb5540cd 100644 --- a/src/shared/agent-session-stored-agent.test.ts +++ b/src/shared/agent-session-stored-agent.test.ts @@ -9,7 +9,7 @@ import { agentSessionStoredAgents } from './agent-session-stored-agent' import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' const GROK = { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } -const WITH_GROK = agentSessionStoredAgents([...CLAUDE_AND_CODEX_STORED_AGENTS.values(), GROK]) +const WITH_GROK = agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, GROK]) function grokRecord(transport = 'acp'): AgentSessionRecord { const record = agentSessionRecordFixture() diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts index 79c90f245e79..f27ded49e8c0 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -1,13 +1,17 @@ /** - * What a record of one structured agent may store, as the agent's definition declares it. + * Which structured agents' records a host admits, and what an agent's definition says its records + * pin. * - * A host admits records only of agents it registered: the runtime hands its registered agents to - * the record store, and a row of any other agent is set aside unread, never rewritten. Nothing here - * names an agent; the list is the runtime's. + * A host admits records only of agents it registered: the runtime hands the record store its + * registered agents' ids, and a row of any other agent is set aside unread, never rewritten. + * Whether a readable record's handles and account variable are the ones its agent's definition + * declares is not the store's question; it is asked only when that agent would start + * (`agentDrivesSession`). Nothing here names an agent; the list is the runtime's. */ 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 @@ -17,18 +21,18 @@ export type AgentSessionStoredAgent = { accountHomeVariable: string } -/** The agents a record store admits, keyed by agent. */ -export type AgentSessionStoredAgents = ReadonlyMap +/** The agents a record store admits: the ids this runtime registered. */ +export type AgentSessionStoredAgents = ReadonlySet export function agentSessionStoredAgents( - agents: readonly AgentSessionStoredAgent[] + agents: readonly Pick[] ): AgentSessionStoredAgents { - const byAgent = new Map() - for (const { agent, handleTransport, accountHomeVariable } of agents) { - if (byAgent.has(agent)) { + const admitted = new Set() + for (const { agent } of agents) { + if (admitted.has(agent)) { throw new Error(`structured agent ${agent} is registered twice`) } - byAgent.set(agent, { agent, handleTransport, accountHomeVariable }) + admitted.add(agent) } - return byAgent + return admitted } From 364f11fea9cd7928a9e972bc8824dfc99ab2d77b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:40:45 -0700 Subject: [PATCH 34/45] docs(agent-session): the record store admits the registered agents' ids --- src/main/runtime/structured-agent-runtime-registrations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index eba577611b3e..69a54d4c9300 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -194,7 +194,7 @@ export function structuredAgentRuntimeRegistration( ) } -/** What the record store admits: exactly the registered agents' declared storage. */ +/** What the record store admits: the ids of exactly the agents registered here. */ export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) ) From 6143fb417997b231375d1a2a8fd2e3b1241a8436 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:58:37 -0700 Subject: [PATCH 35/45] test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer Uses an audience production sends (one that cannot show every agent), per review. --- .../structured-agent-session-restart-offer-newer-orca.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 689a33246a8f..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 @@ -156,7 +156,8 @@ it('still offers a damaged chat, and files its failure when acting on it', async it.each([ ['after the dialog listed the offers', true, undefined], ['on a host that has not listed them yet', false, undefined], - ['for a client shown every agent', false, () => true] + // 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, audience) => { From 2a1cef290f3f47f5b6210c1a77f130edff61740f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:38:57 -0700 Subject: [PATCH 36/45] chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge --- src/main/runtime/agent-session-record-store.ts | 8 +++++--- src/main/runtime/agent-session-recovery-capsule.ts | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 68605ed14c89..a9c85c5c7dd8 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -94,10 +94,12 @@ export class AgentSessionRecordStore { agents: AgentSessionStoredAgents }): AgentSessionRecordStore { const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId, args.agents) - return new AgentSessionRecordStore( - new AgentSessionStoreTransactions(args.journalDatabase, loaded, args.agents), - args.hostId + const transactions = new AgentSessionStoreTransactions( + args.journalDatabase, + loaded, + args.agents ) + return new AgentSessionRecordStore(transactions, args.hostId) } private get state(): AgentSessionStoreState { diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index e4b73c25d29c..ba34c82fc57c 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -211,13 +211,11 @@ export class AgentSessionRecoveryCapsule { ): Promise { return withFileTransactionLock(this.filePath, async () => { const named = sessionIds === 'all' ? null : new Set(sessionIds) - const chosen = (marker: AgentSessionResumeMarker) => - (named?.has(marker.sessionId) ?? true) && !keep(marker) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] - .filter((record) => chosen(record.marker)) + .filter(({ marker }) => (named?.has(marker.sessionId) ?? true) && !keep(marker)) .map((record) => record.marker.sessionId) ) if (dismissed.size > 0) { From fb8bcb8ad31f97ed5158a7f6e7e7710131b49c96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:58 -0700 Subject: [PATCH 37/45] test(native-chat): build the Stop-opened-turn test's identity and turn context the current way The test (#25056) landed before the opaque provider handle (#24991), so main still built the old {kind, threadId} handle; the turn context also needs this branch's agent registry. --- .../structured-agent-session-stop-note-opened-turn.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index bd425db0824d..66af1b8b8ef4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -10,8 +10,10 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -21,7 +23,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'workspace-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const TURN = { provider: 'codex' as const, threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } @@ -47,6 +49,8 @@ async function stopWhileTheTurnOpens( sessionId: 'session-1', journal, fence: 1, + agents: NO_STRUCTURED_AGENTS, + agent: 'codex', adapter: { acquire: vi.fn(), dispatch: vi.fn(), From e35bf185aedc3ad7c3df69ca2f536748374cb51f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:13:22 -0700 Subject: [PATCH 38/45] test(ratchet): require src/main/provider-process now that it has landed --- config/scripts/check-runtime-electron-ratchet.mjs | 6 +++--- config/scripts/check-runtime-electron-ratchet.test.mjs | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 19810c2f4020..3e04b8c46f25 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -48,9 +48,9 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'main', 'codex'] }, { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, - // Allowed absent until they land; every other lane throws if missing, so a rename can't empty it. - { directory: ['src', 'main', 'acp'], mayBeAbsent: true }, - { directory: ['src', 'main', 'provider-process'], mayBeAbsent: true } + { directory: ['src', 'main', 'provider-process'] }, + // Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it. + { directory: ['src', 'main', 'acp'], mayBeAbsent: true } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 1f4f37c01a90..a8985f0c014f 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -32,12 +32,13 @@ describe('structured chat coverage', () => { return root } - // Every lane that must exist; acp/ and provider-process/ may be absent until they land. + // Every lane that must exist; acp/ may be absent until it lands. const requiredLanes = { 'src/main/native-chat/reader.ts': 'export {}', 'src/main/claude/claude-session.ts': 'export {}', 'src/main/codex/codex-session.ts': 'export {}', 'src/main/runtime/structured-agent-session-host.ts': 'export {}', + 'src/main/provider-process/provider-process-teardown.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -89,7 +90,7 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(5) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6) } ) @@ -128,7 +129,7 @@ describe('the default entry points', () => { } }) - // Retires the temporary flag: the PR that adds acp/ or provider-process/ must make it required. + // Retires the temporary flag: the PR that adds acp/ must make it required. it('lets only directories that have not landed yet be absent', () => { for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) { expect( From 5b759286bc86694331ac364052480b8c3a889072 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:16:59 -0700 Subject: [PATCH 39/45] test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test Main's #25706 and this branch both added the import at different lines; the merge kept both. --- .../structured-agent-session-stop-note-opened-turn.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index 268b4f28f781..d514035812f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -11,7 +11,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' From 97020825d4ec29b4f4a8da46082dde7ec9c8af5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:33:13 -0700 Subject: [PATCH 40/45] Keep saved chats readable without provider registration --- .../structured-agent-session-attach-flow.ts | 3 +- ...red-agent-session-conversation-lifetime.ts | 6 - ...ructured-agent-session-drivability.test.ts | 108 ++++++++++++++++-- .../structured-agent-session-host.test.ts | 4 +- .../structured-agent-session-owner-status.ts | 7 +- ...ructured-agent-session-provider-support.ts | 18 +-- ...ed-agent-session-readable-restorer.test.ts | 1 - ...uctured-agent-session-readable-restorer.ts | 12 +- ...ed-agent-session-restart-failure-ledger.ts | 9 +- ...tured-agent-session-restart-resume-host.ts | 1 - .../structured-agent-session-reveal.test.ts | 25 ++-- .../structured-agent-session-reveal.ts | 11 +- ...agent-session-legacy-record-import.test.ts | 10 +- .../agent-session-legacy-record-import.ts | 18 +-- ...session-provider-handle-transition.test.ts | 8 +- ...t-session-record-conversation-name.test.ts | 14 +-- src/main/runtime/agent-session-record-rows.ts | 12 +- .../agent-session-record-store-file.ts | 21 ++-- ...agent-session-record-store-test-harness.ts | 7 +- .../runtime/agent-session-record-store.ts | 9 +- src/main/runtime/agent-session-store-draft.ts | 6 +- .../runtime/agent-session-store-row-rules.ts | 8 +- .../agent-session-store-transactions.ts | 6 +- ...structured-agent-registration-seam.test.ts | 12 +- .../structured-agent-session-at-rest.test.ts | 17 --- .../structured-agent-runtime-registrations.ts | 15 +-- .../structured-agent-session-journal-open.ts | 6 +- .../structured-agent-session-runtime.ts | 5 +- ...-agent-session-startup-tab-restore.test.ts | 10 +- ...tructured-agent-stored-definitions.test.ts | 12 -- src/shared/agent-session-account-home.test.ts | 13 +-- .../agent-session-record-stored-form.test.ts | 13 +-- src/shared/agent-session-record.ts | 20 ++-- ...agent-session-stored-agent.test-fixture.ts | 7 -- src/shared/agent-session-stored-agent.test.ts | 50 ++++---- src/shared/agent-session-stored-agent.ts | 27 +---- 36 files changed, 206 insertions(+), 325 deletions(-) delete mode 100644 src/shared/agent-session-stored-agent.test-fixture.ts 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 fc6bb15a1f98..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 @@ -107,7 +107,8 @@ export async function performAttach( // transport or account variable is not its agent's) is refused; reading it never is. const supported = (record: AgentSessionRecord | null) => record === null - ? adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) + ? 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 (!supported(store.getRecord(sessionId))) { 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 index 1fba19a4a1aa..bbcadedabfef 100644 --- 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 @@ -6,9 +6,9 @@ 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 { agentSessionStoredAgents } from '../../../shared/agent-session-stored-agent' 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' @@ -23,6 +23,8 @@ import { openTestAttachConversation } from './structured-agent-session-attach-te 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' @@ -53,7 +55,6 @@ const GROK: StructuredAgentDefinition = { effortDefaultsToModel: false } } -const STORAGE = agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, GROK]) // 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 @@ -98,6 +99,7 @@ function params( function grokAdapter(): StructuredAgentSessionAdapter { return { + supportsLocation: () => true, acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ @@ -124,7 +126,7 @@ async function attach(input: { spawnToken: string store?: AgentSessionRecordStore }) { - const store = input.store ?? (await openTestAgentSessionRecordStore(root!, { agents: STORAGE })) + const store = input.store ?? (await openTestAgentSessionRecordStore(root!)) const result = await performAttach({ agents: input.agents, logger: createStructuredAgentSessionLogger(), @@ -150,9 +152,8 @@ async function attach(input: { return { result, store } } -/** A Grok chat created while Grok spoke ACP and pinned GROK_HOME, its child since gone, read back - * by a build that registers Grok as `relaunched` declares it. */ -async function createdGrokChat(relaunched: StructuredAgentDefinition = GROK) { +/** 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(), @@ -161,9 +162,7 @@ async function createdGrokChat(relaunched: StructuredAgentDefinition = GROK) { spawnToken: 'spawn-a' }) expect(created.result).toMatchObject({ ok: true }) - const store = await openTestAgentSessionRecordStore(root, { - agents: agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, relaunched]) - }) + const store = await openTestAgentSessionRecordStore(root) await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } } @@ -173,13 +172,102 @@ const UNSUPPORTED = { 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(changed) + const { store, fence } = await createdGrokChat() const adapter = grokAdapter() const { result } = await attach({ 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-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-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index c6db59498ea1..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 @@ -30,17 +30,6 @@ export function adapterSupportsCreateIfDeclared( return adapterSupportsCreate(adapter, location, agent) } -export function adapterSupportsRecord( - adapter: StructuredAgentSessionAdapter, - 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 -} - /** 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); @@ -62,7 +51,7 @@ export function agentDrivesSession( /** 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 chat asks only `adapterSupportsRecord`. */ + * none offers what the start refuses. Reading the stored chat needs no adapter. */ export function hostCanStartRecord( deps: { adapter: StructuredAgentSessionAdapter @@ -70,5 +59,8 @@ export function hostCanStartRecord( }, record: AgentSessionRecord ): boolean { - return adapterSupportsRecord(deps.adapter, record) && agentDrivesSession(deps.agents, record) + 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-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 60d4d396b5e3..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,8 +19,6 @@ 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, @@ -79,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, @@ -108,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) 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 c1167a5b84ef..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 @@ -128,7 +128,6 @@ export function createStructuredAgentSessionRestartResume( 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), 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..ff1b1d4d7d0a 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 } as never }, 'session-answered', open ) @@ -77,7 +71,7 @@ describe('the host answer a client acts on', () => { const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( - { store: { getRecord: () => null } as never, adapter: {} as never }, + { store: { getRecord: () => null } as never }, 'session-absent', open ) @@ -85,21 +79,19 @@ describe('the host answer a client acts on', () => { 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') } as never }, '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 +99,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') } as never }, '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..7fc5e0a8839e 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: 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/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 6eeeba7eda1d..d9a1d0268ab2 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -25,7 +25,6 @@ import { AgentSessionRecordStore } from './agent-session-record-store' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' const NOW = 1_800_000_000_000 const ALPHA = 'session-alpha' @@ -125,8 +124,7 @@ async function install(): Promise<{ const database = await openStructuredAgentSessionJournalDatabase({ logger: log.logger, stateDirectory: root, - hostId: 'local', - agents: CLAUDE_AND_CODEX_STORED_AGENTS + hostId: 'local' }) opened.push(database) // Each report reaches the log as one entry under its scope, its kind as the outcome. @@ -137,8 +135,7 @@ async function install(): Promise<{ database, store: AgentSessionRecordStore.open({ journalDatabase: database, - hostId: 'local', - agents: CLAUDE_AND_CODEX_STORED_AGENTS + hostId: 'local' }), reports } @@ -416,8 +413,7 @@ describe('the tab index from before the table', () => { .run(BETA, JSON.stringify(storedForm(record(BETA)))) await AgentSessionRecordStore.open({ journalDatabase: owed.database, - hostId: 'local', - agents: CLAUDE_AND_CODEX_STORED_AGENTS + hostId: 'local' }).setSessionTabVisibility(BETA, true) owed.database.close() await rm(legacyPath(), { recursive: true }) diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index d92a9254c8de..f632f008e764 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -26,7 +26,6 @@ import { type AgentSessionStoreImportRows } from './agent-session-record-rows' import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export type LegacyAgentSessionRecords = | { kind: 'absent' } @@ -53,15 +52,10 @@ function readFailedTransiently(error: unknown): boolean { /** Never throws. */ export async function readLegacyAgentSessionRecords( stateDirectory: string, - hostId: string, - agents: AgentSessionStoredAgents + hostId: string ): Promise { try { - const loaded = await loadAgentSessionStore( - legacyAgentSessionStorePath(stateDirectory), - hostId, - agents - ) + const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) return loaded.storeFound ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } : { kind: 'absent' } @@ -102,8 +96,7 @@ function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): A function importRows( state: AgentSessionStoreState, - fromBackup: boolean, - agents: AgentSessionStoredAgents + fromBackup: boolean ): AgentSessionStoreImportRows { const records = new Map(state.records) if (fromBackup) { @@ -120,7 +113,7 @@ function importRows( // A record the load rules refuse is kept as its bytes, which every load then sets aside. rows.push([ sessionId, - isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json), agents) + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) ? json : JSON.stringify(encodeAgentSessionRecord(loaded)) ]) @@ -144,7 +137,6 @@ function importRows( export function legacyAgentSessionRecordImport( legacy: LegacyAgentSessionRecords, hostId: string, - agents: AgentSessionStoredAgents, report: (report: LegacyAgentSessionRecordImportReport) => void ): JournalLegacyRecordImport { switch (legacy.kind) { @@ -161,7 +153,7 @@ export function legacyAgentSessionRecordImport( } let rows: AgentSessionStoreImportRows try { - rows = importRows(legacy.state, legacy.fromBackup, agents) + rows = importRows(legacy.state, legacy.fromBackup) } catch (error) { report({ kind: 'unusable', error }) return NO_LEGACY_JOURNAL_RECORDS diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts index 3447a659c4b7..e58827d2fd54 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.test.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -6,7 +6,6 @@ import { import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { recordAgentSessionProviderHandle, reviseAgentSessionProviderResumePoint @@ -76,12 +75,7 @@ describe('reviseAgentSessionProviderResumePoint', () => { handle: { resumeCursor: 'leaf-3' } }) expect(second.lease.provenHandleLinkId).toBe('link-1') - expect( - isPersistedAgentSessionRecord( - encodeAgentSessionRecord(second), - CLAUDE_AND_CODEX_STORED_AGENTS - ) - ).toBe(true) + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(second))).toBe(true) }) it('refuses a stale owner, a released lease, and a head minted by another owner', () => { diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts index 058c0b526334..f9a3cfb3c91d 100644 --- a/src/main/runtime/agent-session-record-conversation-name.test.ts +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -6,7 +6,6 @@ import { import { encodePersistedAgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' const NOW = 9_000 @@ -14,15 +13,10 @@ const NOW = 9_000 function storedRecordIsValid( record: Omit & { conversationName?: unknown } ): boolean { - return isPersistedAgentSessionRecord( - { - ...record, - providerHandleChain: encodePersistedAgentSessionProviderHandleChain( - record.providerHandleChain - ) - }, - CLAUDE_AND_CODEX_STORED_AGENTS - ) + return isPersistedAgentSessionRecord({ + ...record, + providerHandleChain: encodePersistedAgentSessionProviderHandleChain(record.providerHandleChain) + }) } describe('agent session record conversationName validation', () => { diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 8f998e2a4a79..bc691038a6b1 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -24,7 +24,6 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' /** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; * no row count can say which. */ @@ -64,8 +63,8 @@ function parseJson(json: string | null): { ok: true; value: unknown } | { ok: fa } } -function unreadableRecordReason(value: unknown, agents: AgentSessionStoredAgents): string { - if (isPersistedAgentSessionRecord(value, agents)) { +function unreadableRecordReason(value: unknown): string { + if (isPersistedAgentSessionRecord(value)) { return 'record_key_session_id_mismatch' } const schemaVersion = @@ -84,8 +83,7 @@ function unreadableRecordReason(value: unknown, agents: AgentSessionStoredAgents */ export function loadAgentSessionStoreRows( db: Database.Database, - hostId: string, - agents: AgentSessionStoredAgents + hostId: string ): AgentSessionStoreState { const state: AgentSessionStoreState = { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -113,7 +111,7 @@ export function loadAgentSessionStoreRows( } const parsed = parseJson(text(row, 'record_json')) const value = parsed.ok ? parsed.value : text(row, 'record_json') - if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value, agents)) { + if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { const { record } = decodePersistedAgentSessionRecord(value) state.records.set(sessionId, { ...record, @@ -121,7 +119,7 @@ export function loadAgentSessionStoreRows( }) } else { state.unreadableRecords.set(sessionId, { - reason: unreadableRecordReason(value, agents), + reason: unreadableRecordReason(value), raw: value }) } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 4c37bfa17ce9..6b5632a87abf 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -18,7 +18,6 @@ import { } from '../../shared/agent-session-record' import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -75,11 +74,7 @@ function emptyState(hostId: string): AgentSessionStoreState { } } -function parseState( - raw: string, - hostId: string, - agents: AgentSessionStoredAgents -): Pick | null { +function parseState(raw: string, hostId: string): Pick | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -134,7 +129,7 @@ function parseState( state.hostId = file.hostId if (typeof file.records === 'object' && file.records !== null) { for (const [sessionId, value] of Object.entries(file.records)) { - const decoded = isPersistedAgentSessionRecord(value, agents) + const decoded = isPersistedAgentSessionRecord(value) ? decodePersistedAgentSessionRecord(value) : null const record = decoded?.record ?? null @@ -225,8 +220,7 @@ function parseState( async function salvageUnreadableRecordsFromBackup( state: AgentSessionStoreState, backupFilePath: string, - hostId: string, - agents: AgentSessionStoredAgents + hostId: string ): Promise { const missing = [...state.unreadableRecords.keys()].filter( (sessionId) => !state.records.has(sessionId) @@ -240,7 +234,7 @@ async function salvageUnreadableRecordsFromBackup( } catch { return } - const backup = parseState(raw, hostId, agents) + const backup = parseState(raw, hostId) if (!backup) { return } @@ -254,8 +248,7 @@ async function salvageUnreadableRecordsFromBackup( export async function loadAgentSessionStore( filePath: string, - hostId: string, - agents: AgentSessionStoredAgents + hostId: string ): Promise { let unusableStoreFound = false for (const [candidate, recoveredFromBackup] of [ @@ -274,13 +267,13 @@ export async function loadAgentSessionStore( } continue } - const parsed = parseState(raw, hostId, agents) + const parsed = parseState(raw, hostId) if (!parsed) { unusableStoreFound = true continue } if (!recoveredFromBackup) { - await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId, agents) + await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) } return { ...parsed, diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index e90aad5497f0..ab879fa159b0 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -12,8 +12,6 @@ import { isAgentSessionProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' import { closeTestJournalHostDatabase, @@ -49,12 +47,11 @@ function databaseFor(stateDirectory: string): Database.Database { /** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ export async function openTestAgentSessionRecordStore( stateDirectory: string, - options: { hostId?: string; agents?: AgentSessionStoredAgents } = {} + options: { hostId?: string } = {} ): Promise { return AgentSessionRecordStore.open({ journalDatabase: openTestJournalHostDatabase(stateDirectory), - hostId: options.hostId ?? TEST_HOST_ID, - agents: options.agents ?? CLAUDE_AND_CODEX_STORED_AGENTS + hostId: options.hostId ?? TEST_HOST_ID }) } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 8547c0ba688e..eed772bcf302 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -72,7 +72,6 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' type AgentSessionOperationSettlement = Parameters[1] @@ -88,15 +87,13 @@ export class AgentSessionRecordStore { readonly hostId: string ) {} - /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is; - * `agents` are the agents it registered, the only ones whose records it reads or writes. */ + /** Reads every structurally valid row, independently of which agents this host can start. */ static open(args: { journalDatabase: JournalHostDatabase hostId: string - agents: AgentSessionStoredAgents }): AgentSessionRecordStore { - const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId, args.agents) - const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows, args.agents) + const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows) return new AgentSessionRecordStore(transactions, args.hostId) } diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index 616ff60b2ca5..e39027d0042a 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -15,7 +15,6 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import type { PersistedAgentSessionTab } from './agent-session-tab-table' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' /** Unreadable rows are derived at load and never written, so the draft shares them. */ export function draftAgentSessionStoreState(state: AgentSessionStoreState): AgentSessionStoreState { @@ -86,13 +85,12 @@ function retiredClaimKeysChanged( */ export function agentSessionStoreDraftRowWrites( published: AgentSessionStoreState, - draft: AgentSessionStoreState, - agents: AgentSessionStoredAgents + draft: AgentSessionStoreState ): AgentSessionStoreRowWrites | null { const records = serializeChangedRows( published.records, draft.records, - (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written, agents), + (sessionId, written) => isReadableAgentSessionStoreRecord(sessionId, written), encodeAgentSessionRecord ) const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index 6f9c5fccf158..d3e7ea1c038d 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -12,17 +12,15 @@ import { import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' 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: one of a registered agent. */ +/** Valid stored identity, independent of provider availability. */ export function isReadableAgentSessionStoreRecord( sessionId: string, - value: unknown, - agents: AgentSessionStoredAgents + value: unknown ): value is PersistedAgentSessionRecord { - return isPersistedAgentSessionRecord(value, agents) && value.sessionId === sessionId + return isPersistedAgentSessionRecord(value) && value.sessionId === sessionId } export function isReadableAgentSessionStoreOperation( diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index c2ec07814fc8..31bddace8731 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -18,7 +18,6 @@ import { draftAgentSessionStoreState, type AgentSessionStoreRowWrites } from './agent-session-store-draft' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' // Why: rows are diffed by identity, so a row changed in place would never be written. Tests and // development builds make that a TypeError; packaged builds skip the walk. @@ -77,8 +76,7 @@ export class AgentSessionStoreTransactions { constructor( private readonly journalDatabase: JournalHostDatabase, - loaded: AgentSessionStoreState, - private readonly agents: AgentSessionStoredAgents + loaded: AgentSessionStoreState ) { freezeRows(loaded, null) this.published = loaded @@ -150,7 +148,7 @@ export class AgentSessionStoreTransactions { const published = this.published const draft = draftAgentSessionStoreState(published) const result = apply(draft) - const writes = agentSessionStoreDraftRowWrites(published, draft, this.agents) + const writes = agentSessionStoreDraftRowWrites(published, draft) return { result, writes, 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 index 400ead611f36..99e4670ead35 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -4,11 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' -import { - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, - STRUCTURED_AGENT_STORAGE, - structuredAgentRuntimeRegistration -} from './structured-agent-runtime-registrations' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' afterEach(() => vi.restoreAllMocks()) @@ -35,12 +31,6 @@ function runtimeAt(location = LOCAL) { } describe('the structured agent registration list', () => { - it('is what the record store admits', () => { - expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) - ) - }) - it("answers createSupport from the agent's own location rule", async () => { const codex = structuredAgentRuntimeRegistration('codex')! const { runtime, installHost } = runtimeAt() 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/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 2ab1c5d0da45..5db941bb8d88 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -1,8 +1,4 @@ -// The structured agents this runtime registers, in one list. Each entry's definition decides which -// agents' records the store admits, its factory builds the adapter the router drives that agent with -// once the store is open, and its location rule and account resolver answer the picker and create -// before any host exists, so none of them can disagree about which agents exist or where they run. -// Adding an agent is one more entry. +// 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' @@ -12,10 +8,6 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r 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 { - agentSessionStoredAgents, - type AgentSessionStoredAgents -} from '../../shared/agent-session-stored-agent' import type { StructuredAgentSessionAdapter, StructuredAgentSessionLifecycleEvent @@ -196,8 +188,3 @@ export function structuredAgentRuntimeRegistration( null ) } - -/** What the record store admits: the ids of exactly the agents registered here. */ -export const STRUCTURED_AGENT_STORAGE: AgentSessionStoredAgents = agentSessionStoredAgents( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition) -) diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index fd9fefa8713a..54c27fbfe963 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -9,7 +9,6 @@ import { type LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' -import type { AgentSessionStoredAgents } from '../../shared/agent-session-stored-agent' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. @@ -35,16 +34,13 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string hostId: string - /** The registered agents: a legacy record of any other agent is imported verbatim, unread. */ - agents: AgentSessionStoredAgents logger: StructuredAgentSessionLogger }): Promise { try { const opened = await JournalHostDatabase.open(args.stateDirectory, async () => legacyAgentSessionRecordImport( - await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId, args.agents), + await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), args.hostId, - args.agents, (report) => reportLegacyRecordImport(args.logger, report) ) ) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index f409d5495eb7..88544a6c8067 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -46,7 +46,6 @@ import { createStructuredAgentEnvironmentResolvers } from './structured-agent-sh import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, - STRUCTURED_AGENT_STORAGE, type StructuredAgentAdapterContext } from './structured-agent-runtime-registrations' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' @@ -223,7 +222,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise ({ failing: false, grants: Infinity, refused: 0 })) @@ -146,8 +145,7 @@ async function seedProfile( const database = await openStructuredAgentSessionJournalDatabase({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local', - agents: CLAUDE_AND_CODEX_STORED_AGENTS + hostId: 'local' }) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence @@ -187,16 +185,14 @@ async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string const database = await openStructuredAgentSessionJournalDatabase({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, - hostId: 'local', - agents: CLAUDE_AND_CODEX_STORED_AGENTS + hostId: 'local' }) database.db .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record))) await AgentSessionRecordStore.open({ journalDatabase: database, - hostId: 'local', - agents: CLAUDE_AND_CODEX_STORED_AGENTS + hostId: 'local' }).setSessionTabVisibility(record.sessionId, true, tabId) database.close() await rm(legacyAgentSessionStorePath(root), { recursive: true }) diff --git a/src/main/runtime/structured-agent-stored-definitions.test.ts b/src/main/runtime/structured-agent-stored-definitions.test.ts index 31510e5cb6a5..9640907cead1 100644 --- a/src/main/runtime/structured-agent-stored-definitions.test.ts +++ b/src/main/runtime/structured-agent-stored-definitions.test.ts @@ -1,15 +1,10 @@ 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_AND_CODEX_STORED_AGENTS } from '../../shared/agent-session-stored-agent.test-fixture' import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE, CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' -import { - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, - STRUCTURED_AGENT_STORAGE -} from './structured-agent-runtime-registrations' describe('what the shipped definitions let a record store', () => { it('pins what every older build wrote', () => { @@ -22,11 +17,4 @@ describe('what the shipped definitions let a record store', () => { accountHomeVariable: 'CODEX_HOME' }) }) - - it('admits exactly the agents the runtime routes, stored as before', () => { - expect([...STRUCTURED_AGENT_STORAGE.keys()]).toEqual( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) - ) - expect(STRUCTURED_AGENT_STORAGE).toEqual(CLAUDE_AND_CODEX_STORED_AGENTS) - }) }) diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index a59ad59f43a7..1986ae112d31 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -3,8 +3,6 @@ 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' -import { agentSessionStoredAgents } from './agent-session-stored-agent' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' describe('agent session account home', () => { it('stores the variable and path exactly as older builds wrote them', () => { @@ -20,20 +18,11 @@ describe('agent session account home', () => { it('reads any well-formed variable, and sets aside one that is not a variable name', () => { const record = encodeAgentSessionRecord(agentSessionRecordFixture()) const withVariable = (variable: string) => - isPersistedAgentSessionRecord( - { ...record, accountHome: { variable, path: '/tmp/x' } }, - CLAUDE_AND_CODEX_STORED_AGENTS - ) + 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) } }) - - it('refuses registering one agent twice', () => { - expect(() => - agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, { agent: 'codex' }]) - ).toThrow('registered twice') - }) }) diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index c78f190a4288..22ef2b54a9ad 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -12,7 +12,6 @@ import { encodePersistedAgentSessionProviderHandle } from './agent-session-provider-handle-encoding' import { isPersistedAgentSessionRecord } from './agent-session-record' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord @@ -98,8 +97,8 @@ describe('a record row older builds wrote', () => { ['Codex', STORED_CODEX_ROW] ])('reads a %s row and writes it back byte for byte', (_name, row) => { const stored: unknown = JSON.parse(row) - expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) - if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { return } const { record, normalized } = decodePersistedAgentSessionRecord(stored) @@ -109,7 +108,7 @@ describe('a record row older builds wrote', () => { it('hands shared code only the neutral handle', () => { const stored: unknown = JSON.parse(STORED_CLAUDE_ROW) - if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { + if (!isPersistedAgentSessionRecord(stored)) { throw new Error('fixture row must be readable') } const { record } = decodePersistedAgentSessionRecord(stored) @@ -242,8 +241,8 @@ describe('what this build writes', () => { stored.providerHandleChain[0].handle.laterHandleField = 'x' stored.providerHandleChain[0].laterLinkField = 'y' stored.laterRecordField = 'z' - expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) - if (!isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)) { + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { return } const written = JSON.parse( @@ -272,7 +271,7 @@ describe('what this build writes', () => { nativeId: 'acp-thread', resumeCursor: 'resume-token' }) - expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(false) + expect(isPersistedAgentSessionRecord(stored)).toBe(false) }) }) diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index eb7e22d24929..f8dea65428ff 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -24,9 +24,11 @@ import { decodePersistedAgentSessionProviderHandleChain, type AgentSessionProviderHandleLink } from './agent-session-provider-handle' -import { isAgentSessionProviderHandleInNamespace } from './agent-session-provider-handle-encoding' +import { + isAgentSessionProviderHandleInNamespace, + isStructuredAgentId +} from './agent-session-provider-handle-encoding' import type { AgentSessionAccountHome } from './agent-session-account-home' -import type { AgentSessionStoredAgents } from './agent-session-stored-agent' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -131,7 +133,7 @@ export type AgentSessionRecord = { schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION sessionId: string location: AgentSessionExecutionLocation - /** The registered agent this session runs; its definition decides what the record may store. */ + /** The agent this session names, whether this build can run it or not. */ provider: string providerHandleChain: AgentSessionProviderHandleLink[] accountHome: AgentSessionAccountHome @@ -338,14 +340,9 @@ 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. - * `agents` are the host's registered agents: a record of any other agent is not readable here. A - * registered agent's record is readable when it agrees with itself; whether this build can drive - * its transport and account variable is decided only when its agent would start. */ +/** Stored identity is independent of registrations; availability is checked only at start. */ export function isPersistedAgentSessionRecord( - value: unknown, - agents: AgentSessionStoredAgents + value: unknown ): value is PersistedAgentSessionRecord { if (typeof value !== 'object' || value === null) { return false @@ -355,8 +352,7 @@ export function isPersistedAgentSessionRecord( record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && isAgentSessionId(record.sessionId) && isAgentSessionExecutionLocation(record.location) && - typeof record.provider === 'string' && - agents.has(record.provider) && + isStructuredAgentId(record.provider) && isAgentSessionAccountHome(record.accountHome) && (record.options === undefined || isAgentSessionOptions(record.options)) && (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && diff --git a/src/shared/agent-session-stored-agent.test-fixture.ts b/src/shared/agent-session-stored-agent.test-fixture.ts deleted file mode 100644 index ca3d41f567cb..000000000000 --- a/src/shared/agent-session-stored-agent.test-fixture.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { agentSessionStoredAgents } from './agent-session-stored-agent' - -/** The agents every older build's record store admitted; a runtime test pins the shipped list to it. */ -export const CLAUDE_AND_CODEX_STORED_AGENTS = agentSessionStoredAgents([ - { agent: 'claude' }, - { agent: 'codex' } -]) diff --git a/src/shared/agent-session-stored-agent.test.ts b/src/shared/agent-session-stored-agent.test.ts index 611efb5540cd..616ff054af1a 100644 --- a/src/shared/agent-session-stored-agent.test.ts +++ b/src/shared/agent-session-stored-agent.test.ts @@ -5,11 +5,6 @@ import { decodePersistedAgentSessionRecord, encodeAgentSessionRecord } from './agent-session-record-stored-form' -import { agentSessionStoredAgents } from './agent-session-stored-agent' -import { CLAUDE_AND_CODEX_STORED_AGENTS } from './agent-session-stored-agent.test-fixture' - -const GROK = { agent: 'grok', handleTransport: 'acp', accountHomeVariable: 'GROK_HOME' } -const WITH_GROK = agentSessionStoredAgents([{ agent: 'claude' }, { agent: 'codex' }, GROK]) function grokRecord(transport = 'acp'): AgentSessionRecord { const record = agentSessionRecordFixture() @@ -24,31 +19,39 @@ function grokRecord(transport = 'acp'): AgentSessionRecord { } } -describe('records of registered agents', () => { - it('reads a record of any agent the host registered', () => { +describe('provider-independent stored records', () => { + it('reads and decodes a well-formed record', () => { const stored = encodeAgentSessionRecord(grokRecord()) - expect(isPersistedAgentSessionRecord(stored, WITH_GROK)).toBe(true) - if (!isPersistedAgentSessionRecord(stored, WITH_GROK)) { + expect(isPersistedAgentSessionRecord(stored)).toBe(true) + if (!isPersistedAgentSessionRecord(stored)) { return } expect(decodePersistedAgentSessionRecord(stored).record).toEqual(grokRecord()) }) - it('sets aside a record of an agent this host did not register', () => { - expect( - isPersistedAgentSessionRecord( - encodeAgentSessionRecord(grokRecord()), - CLAUDE_AND_CODEX_STORED_AGENTS - ) - ).toBe(false) + 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')), WITH_GROK) - ).toBe(true) + expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(grokRecord('other')))).toBe(true) }) it('sets aside a record whose handles name another agent, or mix transports', () => { @@ -59,8 +62,7 @@ describe('records of registered agents', () => { for (const chain of [[foreign], [link!, mixed]]) { expect( isPersistedAgentSessionRecord( - encodeAgentSessionRecord({ ...record, providerHandleChain: chain }), - WITH_GROK + encodeAgentSessionRecord({ ...record, providerHandleChain: chain }) ) ).toBe(false) } @@ -68,8 +70,7 @@ describe('records of registered agents', () => { it('keeps reading Claude and Codex records exactly as before', () => { const stored = encodeAgentSessionRecord(agentSessionRecordFixture()) - expect(isPersistedAgentSessionRecord(stored, CLAUDE_AND_CODEX_STORED_AGENTS)).toBe(true) - expect(isPersistedAgentSessionRecord(stored, WITH_GROK)).toBe(true) + expect(isPersistedAgentSessionRecord(stored)).toBe(true) expect(JSON.stringify(stored.providerHandleChain[0]?.handle)).toBe( '{"provider":"claude","sessionId":"provider-session-alpha-1","leafUuid":null}' ) @@ -79,8 +80,7 @@ describe('records of registered agents', () => { const record = agentSessionRecordFixture() expect( isPersistedAgentSessionRecord( - encodeAgentSessionRecord({ ...record, provider: 'codex', accountHome: record.accountHome }), - CLAUDE_AND_CODEX_STORED_AGENTS + 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 index f27ded49e8c0..a500a604de7c 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -1,13 +1,4 @@ -/** - * Which structured agents' records a host admits, and what an agent's definition says its records - * pin. - * - * A host admits records only of agents it registered: the runtime hands the record store its - * registered agents' ids, and a row of any other agent is set aside unread, never rewritten. - * Whether a readable record's handles and account variable are the ones its agent's definition - * declares is not the store's question; it is asked only when that agent would start - * (`agentDrivesSession`). Nothing here names an agent; the list is the runtime's. - */ +/** The identity an agent must match when starting a stored session. */ import type { AgentSessionProviderTransport } from './agent-session-provider-handle' @@ -20,19 +11,3 @@ export type AgentSessionStoredAgent = { /** Environment variable naming the agent's config directory, pinned as the record's account home. */ accountHomeVariable: string } - -/** The agents a record store admits: the ids this runtime registered. */ -export type AgentSessionStoredAgents = ReadonlySet - -export function agentSessionStoredAgents( - agents: readonly Pick[] -): AgentSessionStoredAgents { - const admitted = new Set() - for (const { agent } of agents) { - if (admitted.has(agent)) { - throw new Error(`structured agent ${agent} is registered twice`) - } - admitted.add(agent) - } - return admitted -} From c3b8f6f1387f3fc180e5d88bfc34143859095987 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:43:51 -0700 Subject: [PATCH 41/45] Keep stored-record compatibility checks independent of registration --- ...oss-version-registered-agents.unit.test.ts | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) 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 index 2a2f670465c4..2f45ad6f345c 100644 --- 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 @@ -7,7 +7,7 @@ // - 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 -// reads the new agent's record as one of its own; +// 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 @@ -21,7 +21,10 @@ import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../src/main/ 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 type { AgentSessionRecord } from '../../../src/shared/agent-session-record' +import { + isPersistedAgentSessionRecord, + type AgentSessionRecord +} from '../../../src/shared/agent-session-record' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import { RUNTIME_CAPABILITIES, @@ -62,10 +65,8 @@ type Baseline = { ) => RuntimeMobileSessionTabsSnapshot parseWorkspaceSession: (raw: unknown) => { ok: boolean; value?: unknown } isStructuredTab: (tab: unknown) => boolean - /** Builds that register agents also take the agents a record may name. */ + /** The optional list supports releases that gated stored reads on registration. */ isPersistedAgentSessionRecord: (value: unknown, agents?: unknown) => boolean - /** What the baseline's own record store admits from Claude and Codex, when it takes a list. */ - claudeAndCodexStoredAgents: unknown } let baseline: Baseline @@ -98,12 +99,6 @@ beforeAll(async () => { ]) current = await loadAgentSessionWireBuild(WORKING_TREE) const wire = await loadAgentSessionWireBuild(ref) - const storedAgents = registersAgents(wire) - ? await importReleaseCheckoutModule( - checkout, - 'src/shared/agent-session-stored-agent.test-fixture.ts' - ) - : null baseline = { ref, wire, @@ -111,15 +106,11 @@ beforeAll(async () => { projectSessionTabAgentStatus: member(projection, 'projectSessionTabAgentStatus'), parseWorkspaceSession: member(schema, 'parseWorkspaceSession'), isStructuredTab: member(tabs, 'isStructuredTab'), - isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord'), - claudeAndCodexStoredAgents: storedAgents - ? member(storedAgents, 'CLAUDE_AND_CODEX_STORED_AGENTS') - : undefined + isPersistedAgentSessionRecord: member(record, 'isPersistedAgentSessionRecord') } }, SUITE_TIMEOUT_MS) -/** Whether a build registers agents beyond Claude and Codex. From then on its saved tabs keep any - * agent's id, and its record store admits exactly the agents its runtime registered. */ +/** 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) } @@ -127,7 +118,7 @@ function registersAgents(build: AgentSessionWireBuild): boolean { function baselineReadsRecord(record: AgentSessionRecord): boolean { return baseline.isPersistedAgentSessionRecord( encodeAgentSessionRecord(record), - baseline.claudeAndCodexStoredAgents + new Set(['claude', 'codex']) ) } @@ -288,9 +279,10 @@ describe('a structured agent beyond Claude and Codex, across versions', () => { ).toEqual(['claude', NEW_AGENT, 'codex']) }) - it("sets aside the new agent's record and reads Claude and Codex records as before", () => { - // A build that registers agents is given the two it shipped: one without the new agent. - expect(baselineReadsRecord(newAgentRecord())).toBe(false) + 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, From c940bf6dc10408590eef3ebfbb225c09f34ffcff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:03:26 -0700 Subject: [PATCH 42/45] Keep saved providers in restart client audiences --- .../structured-agent-session-host.ts | 8 + .../structured-agent-session-policy.ts | 6 +- ...tured-agent-session-restart-resume.test.ts | 2 +- ...structured-agent-session-restart-resume.ts | 6 +- ...session-restart-unregistered-agent.test.ts | 158 ++++++++++++++++++ 5 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts 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 6647c6ce4fb6..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 @@ -216,6 +216,14 @@ export class StructuredAgentSessionHost { /** 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/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 479b67289c93..0892f35d7976 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -44,17 +44,17 @@ export function clientRendersStructuredAgent( ) } -/** The agents this client reads rows of, among those this host `registered`; undefined when it +/** 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, - registered: readonly { agent: string }[] + agents: readonly string[] ): ((agent: string) => boolean) | undefined { if (context.clientKind === undefined) { return undefined } const reads = (agent: string) => clientRendersStructuredAgent(context.clientCapabilities, agent) - return registered.every(({ agent }) => reads(agent)) ? undefined : reads + return agents.every(reads) ? undefined : reads } /** 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 index 8101473f894b..aacdcdc7f74e 100644 --- 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 @@ -72,7 +72,7 @@ function installRestartHost() { setStructuredAgentSessionHost( Object.assign(hostStub(), { restartResume, - agentDefinitions: () => [{ agent: 'claude' }, { agent: 'codex' }, { agent: 'grok' }] + knownAgentIds: () => ['claude', 'codex', 'grok'] }) ) return { restartResume, offers: () => offers } 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 bb68f2b0dcad..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 @@ -21,7 +21,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - const audience = structuredAgentsReadBy(ctx, host.agentDefinitions()) + const audience = structuredAgentsReadBy(ctx, host.knownAgentIds()) return { sessions: await host.restartResume.list(audience), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. @@ -38,7 +38,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) // Offers this client was never shown stay for a client that can show them. - const audience = structuredAgentsReadBy(ctx, host.agentDefinitions()) + const audience = structuredAgentsReadBy(ctx, host.knownAgentIds()) const dismissed = await host.restartResume.dismiss(params.sessionIds, audience) if (params.sessionIds === undefined) { // The dismissal removed every pending and in-flight record this client sees, so a second @@ -64,7 +64,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ return host.restartResume.continueAfterRestart( params.sessionIds, structuredCallerFor(ctx).callerKey, - structuredAgentsReadBy(ctx, host.agentDefinitions()) + 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([]) + } +) From 65653f96e888a141d58a4d8c088502bbca6ae651 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:44:30 -0700 Subject: [PATCH 43/45] test(native-chat): type reveal fixtures without assertions --- .../structured-agent-session-reveal.test.ts | 12 ++++-------- .../structured-agent-session-reveal.ts | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) 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 ff1b1d4d7d0a..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 @@ -53,7 +53,7 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( - { store: { getRecord: () => stored } as never }, + { store: { getRecord: () => stored } }, 'session-answered', open ) @@ -70,11 +70,7 @@ 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 }, - 'session-absent', - open - ) + revealStructuredAgentSession({ store: { getRecord: () => null } }, 'session-absent', open) ).rejects.toThrow('agent_session_identity_required') expect(open).not.toHaveBeenCalled() }) @@ -85,7 +81,7 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( { - store: { getRecord: () => record('codex', 'workspace-1') } as never + store: { getRecord: () => record('codex', 'workspace-1') } }, 'session-answered', open @@ -99,7 +95,7 @@ describe('the host answer a client acts on', () => { await expect( revealStructuredAgentSession( { - store: { getRecord: () => record('codex', 'workspace-1') } 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 7fc5e0a8839e..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 @@ -26,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 { From c336e75164109c4cf030ab9dea033f88ec1af40a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:16:16 -0700 Subject: [PATCH 44/45] test(wire): expose known agents in structured host fixture --- .../structured-agent-session-host-fixture.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0a7d2d9ee38b..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 @@ -95,7 +95,8 @@ export function structuredHostStub( // opens empty and a subscriber that was away has missed what passed. subscribeTurnCompletions: vi.fn(() => () => undefined), unsubscribe: vi.fn(), - agentDefinitions: vi.fn(() => [CODEX_STRUCTURED_AGENT]) + agentDefinitions: vi.fn(() => [CODEX_STRUCTURED_AGENT]), + knownAgentIds: vi.fn(() => [CODEX_STRUCTURED_AGENT.agent]) } } From 40fa4aace610902c414042ec82c198313a59409f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:03:16 -0700 Subject: [PATCH 45/45] Supply startability dependency in the new Codex catalog fixture --- src/main/codex/codex-structured-session-options-catalog.test.ts | 1 + 1 file changed, 1 insertion(+) 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 } })