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 1/6] 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 2/6] 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 3/6] 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 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 4/6] 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 5/6] 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 6/6] 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) })