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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 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 08/16] 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 09/16] 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 10/16] 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 11/16] 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 12/16] 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 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 13/16] 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 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 14/16] 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 15/16] 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 16/16] 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'