From e817b0e23747ffd6f16f2ddefea861950d82a3c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:48:12 -0700 Subject: [PATCH] refactor(native-chat): keep the provider resume handle opaque to shared code (#24991) * 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. * 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. * 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. * test(native-chat): use opaque handle in queued rejection fixture * 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. * 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. --- .../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 +- ...ed-session-acquisition-processless.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-crash-boundary.test.ts | 3 +- .../journal-damage.test.ts | 3 +- .../journal-dispatch-reason-bound.test.ts | 3 +- .../journal-epoch-replacement.test.ts | 5 +- .../journal-epoch-replacement.ts | 3 +- .../journal-epoch-rollover.ts | 3 +- .../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-queued-rejection-batch.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 | 8 +- .../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 | 8 +- .../journal-unknown-content.test.ts | 3 +- .../journal-unknown-row-kind.test.ts | 8 +- .../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 | 8 +- .../agent-session-history-page.test.ts | 3 +- ...-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 +- ...t-session-stop-note-withdrawn-send.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 | 31 +- ...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 | 26 +- ...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 | 307 +++++++++++++++++ .../agent-session-provider-handle.test.ts | 144 +++++--- src/shared/agent-session-provider-handle.ts | 162 ++++++--- .../agent-session-record-stored-form.test.ts | 318 ++++++++++++++++++ .../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 +- ...nt-session-newer-chat-refusal.unit.test.ts | 18 +- ...-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 +- 206 files changed, 1698 insertions(+), 644 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 ed347670277..ecbc2353849 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 b7104e50a0c..d363e47627f 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 dcbd00776fd..695acefbb6f 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 843fbb281d3..f2933b8a702 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 be2c1569ead..33ce088cca0 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 d98ef7583df..5473719ce11 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 e41b71e1a2c..b40d16b633b 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 082ed4c12ce..bd79a4a2c9d 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 b3764b16d99..4de526c7c4d 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 002240e4dd7..aa535a12a32 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 851831b86f1..e5ed29561cb 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 00ae8692025..fc5c661a690 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 0cf7b30368a..4aa02ed525c 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 66fed64c323..9db732d3430 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 29703dfd8eb..979b5ae5963 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 592b61df338..729aecbec87 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 e8f251d41f9..80f6ca6977d 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 4cae8b465e9..3e67d35f7b4 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 5ce21d83f17..13e4ee5e672 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 af6719938be..b12ca6ff1e0 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -21,6 +21,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})` : ''}` @@ -66,7 +67,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) } } @@ -158,13 +159,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 862909a2cd6..9465feba5d3 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({ 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-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index eb4babdb9ac..a75f82b72db 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/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index ee14b6b525a..cd5b4841de6 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 a91b1d7c193..86598780b45 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 () => { @@ -296,9 +300,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') } @@ -385,11 +389,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', @@ -411,11 +411,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) @@ -432,11 +428,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 dd7ad1e8d8c..02cf46aadc8 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 4cb72eaa53a..3ffd74aff14 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({ resumeCursor: '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: { 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({ leafUuid: '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. @@ -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 c4b848eee44..d95dd3a48ff 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 0e3e8385685..722858828f2 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 f0737fc36a3..f86d0aff6c1 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 7513ec1fbbe..9ac0f7f31bd 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 aa455403ea7..11b559655b4 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 a99b815bff4..87b888ec1a0 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 07115c4b4e7..0f51961b64d 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 da24f836d1a..05748ead80f 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 36ab39e3bab..1e6a6b4805e 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 ebfdb27200a..19945c9a6c8 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 c86885171e4..229da5e00bd 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 805a767e620..9b0d3e8e548 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 78c197644e2..c63d78439ab 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 1b704fa3946..170c142ba43 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 37d434ef1a4..6a7c9f2af5a 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 2e553cb9e0b..3066b561336 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 0e674bf10de..c9e3c14cac9 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 () => { @@ -58,7 +59,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 @@ -102,7 +103,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 () => { @@ -132,7 +133,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 4e2515da7d4..466cadce8a1 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 939d5faf0cd..2d1c999d19c 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 54cebde991e..4f9f9a2ab0d 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 3883dad4600..44ed5c93d41 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-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index a6821cf8427..278fea93973 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-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts index 2ced3e4b2a9..8b59940a6a1 100644 --- a/src/main/native-chat/agent-session-journal/journal-damage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts @@ -14,6 +14,7 @@ import { type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type Database from '../../sqlite/sync-database' import type { openAgentSessionJournal } from './journal-store-factory' import { @@ -31,7 +32,7 @@ const IDENTITY: AgentSessionJournalIdentity = { workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') } const UNLOADABLE = { 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 cefd4bbe368..574dec492ee 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 2992459a787..6337b00417b 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 1921bc82a45..da53b2e9a11 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 @@ -10,6 +10,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 { applyJournalRow, createJournalReducerState } from './journal-reducer' @@ -56,7 +57,7 @@ export function replaceJournalEpoch(input: { const epochRow: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.identity.providerHandle, + 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 7f9abfc408f..477a79f11ed 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 @@ -6,6 +6,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 { applyJournalRow, createJournalReducerState } from './journal-reducer' @@ -30,7 +31,7 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.identity.providerHandle, + 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-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index 84fb299fb05..eed26b20388 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 @@ -25,13 +25,14 @@ import { liveTestJournalRows, SAVED_BY_NEWER_ORCA } 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 2b4e4a0819a..4f209c2f202 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 3d835c21b73..89e88b247ae 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 6c291c3a378..53397f87044 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 @@ -33,6 +33,7 @@ import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal, previewPerSessionJournal } 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() @@ -44,7 +45,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 d41e9b363ed..8dea06cebee 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-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts index d218a356fae..cd14ce2fe94 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'), { 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 2f28adf0d54..8e541f110b0 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 0865df3a41f..ed40d490951 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), ...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 9dcced9944a..28dd96f9f54 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 616f4b55756..4fdaaa880b1 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 f0ebac8622b..847c99734a2 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 @@ -29,7 +29,7 @@ import { type AgentJournalMessageItem, type AgentJournalProducerLinkage, type AgentJournalTurnScope, - type AgentSessionProviderHandle + type AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' import { readAgentJournalItemBody, @@ -67,7 +67,7 @@ type JournalRowBase = AgentJournalProducerLinkage & { export type JournalEpochRow = JournalRowBase & { kind: 'epoch' reason: AgentJournalEpochReason - providerHandle: AgentSessionProviderHandle + providerHandle: AgentSessionJournalProviderHandle } export const AGENT_JOURNAL_EPOCH_REASONS = [ @@ -131,7 +131,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 7b8e9fe5935..0785e156738 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,17 @@ import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { + agentSessionJournalProviderHandle, + 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' @@ -64,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-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index 18cb7efa47a..75fd12db242 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 { insertTestJournalRowJson, SAVED_BY_NEWER_ORCA } 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 fcb541b8faa..a3bd5be348c 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 855c6ab3e98..90d5a826946 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -321,7 +321,7 @@ export class AgentSessionJournal { receipt?: JournalOperationReceipt ): 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), receipt ) 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 6b519ce35cd..4da76e0ba42 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 fc943164615..f9588b71ba3 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,17 @@ 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 { + agentSessionJournalProviderHandle, + 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', @@ -132,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-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts index e56bcc92c96..effd8a8136e 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts @@ -13,6 +13,7 @@ import { type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { closeTestJournalHostDatabase, createTrackedJournalOpener, @@ -27,7 +28,7 @@ const IDENTITY: AgentSessionJournalIdentity = { 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-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index bb965817b59..9aecd47da97 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 @@ -22,6 +22,10 @@ import { SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' const UNLOADABLE = { refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } @@ -32,7 +36,7 @@ const IDENTITY: AgentSessionJournalIdentity = { 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 } @@ -116,7 +120,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-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 3cdd554cc4e..9b5653b060f 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 15911b5e080..5f9a95a53d2 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 4ce62c6192b..a12630d6566 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 b8e45e0ef59..a2fa19a6a5c 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 6c609853d69..abad56fa6dd 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 7711b670438..115e2248ea0 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 8438b40d122..9cbfb94a907 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 8abfce258b8..f94624c26c6 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,17 @@ 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 { + agentSessionJournalProviderHandle, + 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 @@ -55,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/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 e40ebcf326e..de31297ee25 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/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 1b4a4e95b8c..3f1b4046f54 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 d73cbb7e0bd..3b2deed8c5a 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 e22dd1bd289..076e2de7540 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 76a276abd23..4f532865452 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 3eccc4f9457..e86e30d8f5d 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 6111e902962..5c718da0f3b 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 140a9920115..4b52c9446de 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 85f99056c3a..415e261ab3c 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 1bb3d36a5c4..868247eb37c 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 9c920b45eb3..198c2418cf9 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 @@ -524,7 +524,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 4f8679578cf..a1e11e0c42d 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 ec4c76e26ba..94d08c054c5 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 d31a79e5f54..a8e0f4be94f 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 @@ -48,6 +48,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. */ @@ -71,7 +72,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 @@ -120,7 +121,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 @@ -435,7 +436,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 @@ -513,7 +514,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 b39790e6b2a..8c3c48c93ad 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 483e88720fd..a2de3f33874 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 fca04e1028a..31e7bb71859 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 38b85ca12d1..d6590d6c61a 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 764d54377d0..f91ab9b822b 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 b82499c2830..a4a12bfce7e 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 b282fc4df32..49fb06e83ba 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 ee0bb2bee92..c444f52c031 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 c7667531110..96fc601134b 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 f66d2506804..16251f3cd40 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 f91fc57c055..81b7200c304 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 c3eb1e324e9..f428ab59a1b 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 c25640cc81c..00b9f617891 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 9ab31743cf4..b8bda4c1b78 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 c7901edc1c1..a5c44bdb0d0 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 8a9d93c35e7..62482870271 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 c45ceac238c..1c55baa57b0 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 f98959bc0ad..d2348e2acfd 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 @@ -17,13 +17,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 6d42155fa38..c61d2bd117f 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 de2951a6292..57d249d2dbe 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 f94d091a5f9..f1271f4e339 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 6f59e5e7d5e..38b2453c3bf 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 0857e3565d6..cbcad01350a 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 228fa21fbc2..314e4012199 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 8e3be9d1f3d..bdeaa152784 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 fefd91a8478..a16fa4f21d8 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 a16ef1101a2..6fc7b6c2d52 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 a2e843378a5..c14899af112 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 0045684ccb3..bd21fa3ff33 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 9f00c69aca4..e5fa9576f6a 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 6cde8d87b84..38a47a06ebe 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 4fa2811472a..20cd3b298d5 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 49a116ec7f7..a574010b34e 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 c7240eccd83..8d5d5d54cbb 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 5a359deebad..6e60651d4bd 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 @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const caller = { callerKey: 'desktop' } let directory: string @@ -78,7 +79,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 ee9c69bf38c..6003bba605a 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 @@ -96,13 +96,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 e170461095b..55acadfc562 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 01f4868d380..c51219ae8e6 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 8c5e609b971..6cf15ca088c 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 429cb803794..bfc7db94ebd 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 @@ -186,7 +187,7 @@ describe('settled attach retry', () => { }, link: { linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, + handle: codexProviderHandle(THREAD), origin: 'created', mintedAtFence: fence, observedAt: NOW @@ -230,7 +231,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 8a9deaad48c..90e5e84cf86 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 79227670238..0afc0a05077 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 cfb801c8fd6..386040a05a6 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 a9c94cc4d2b..20a7fca25c1 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 30ddeea9678..89e47785983 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 09a3442985b..f7893ea0a44 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 6409c544129..828063da409 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 aea5e93168a..611b27f08f9 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-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index a3c8853ef67..521f9d888cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -12,6 +12,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -21,7 +22,7 @@ const IDENTITY: AgentSessionJournalIdentity = { 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-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index d47efcd4cad..3dcc899b081 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 27ae0cb0608..a20302cba6a 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') }) @@ -222,7 +226,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') }) @@ -288,7 +292,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') }) @@ -337,7 +341,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') }) @@ -389,7 +393,7 @@ describe('AgentSessionSubscribers', () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: journalDir }) @@ -440,7 +444,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 c681369ce27..9db5951336e 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 6349e7b8ef6..5ab883380d5 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 b2f30032aa9..0cbf5b8ce6c 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 08532f63c1f..242f7c22877 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 24ae62a872b..42cc18f923c 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 22766f28a41..41031cb3b3e 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 ad211902433..d34bb5ac87f 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 581bb29c364..cdfb26842eb 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 e64e8f799d8..662b93dcb2c 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 9904ca7994f..73d01b5b88e 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 ec636a96eed..a20143780ec 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 0b5b6e5f2ef..1737ad45d3f 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 bf2156d0710..3a9e47fe6c2 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 0a0b4d49d5b..f632f008e76 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 7a61ea7bdfa..e58827d2fd5 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({ resumeCursor: '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({ resumeCursor: '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: { resumeCursor: '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, - providerSessionId: 'another-provider-session', - leafUuid: 'leaf-2', + 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, + 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 57d2ecb172c..a2c94244198 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 084cc93ac4d..f9a3cfb3c91 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 58385e29272..c092018ea27 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 be6f70f06e7..1b1fe6e1b3b 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 79e65388fbd..6b5632a87ab 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 12aa70b330c..cc0a83cc55f 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 }) @@ -739,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', + resumeCursor: '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/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e5f54265dcb..8e02a2660af 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 cad534931e6..0729ebfc35b 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 2afdfc13484..e39027d0042 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 ab268c08db7..ec04a35c38d 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 31e473fb5e6..a6f002170db 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 b75b4a06024..bbafd5f1aae 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -29,6 +29,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) @@ -769,19 +770,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; resumeCursor?: 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', + 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. @@ -807,11 +809,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 a5dffbeb48a..839f80f2b81 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 d00d411a067..b6e580fc77e 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 a68ffe4492e..1992d3c785a 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 @@ -15,6 +15,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' @@ -43,7 +44,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 5c9633a5690..7b13887e1aa 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 @@ -30,6 +30,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 = { @@ -70,7 +71,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 9fe1f2083bb..bea8b49039b 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 @@ -40,6 +40,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}` @@ -75,10 +76,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 396b0b2bec7..e200260a7e2 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 25379632875..ff82a61406e 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 5afd2d2e770..5f6020e8d0f 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 9926aaba2dc..c55bdb2f932 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 { journalOpenHold } = vi.hoisted(() => { const journalOpenHold: { next: { entered: () => void; released: Promise } | null } = { @@ -137,7 +138,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 571e903dde3..5ff7cb3029f 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -45,6 +45,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() @@ -52,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. @@ -402,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: { kind: 'codex' as const, threadId: THREAD } - } const journal = await journals.open({ - identity, + identity: JOURNAL_IDENTITY, stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') @@ -823,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: { kind: 'codex' as const, threadId: THREAD } - } const reopened = await journals.open({ - identity, + identity: JOURNAL_IDENTITY, stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') 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 dd76d01e6c6..5c2256ad882 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 2727b2389f2..706b4de5bce 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 aee47ab15ba..16cb94a812f 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 e0bfa5bbdd3..c01c30b6b89 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 @@ -30,7 +30,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' @@ -40,6 +41,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 })) @@ -97,7 +99,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') } } @@ -131,7 +133,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: {}, @@ -185,7 +189,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 0ec0418184d..29a2d5bb79f 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 732515cdde6..8b4b70f8bc2 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' @@ -115,11 +116,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 9613afa8a84..d53015fc725 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 49a10558b81..5bd90fbc71e 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 ea4ef2909e8..ce06ddce0eb 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 00000000000..c0c2f6c6f57 --- /dev/null +++ b/src/shared/agent-session-provider-handle-encoding.ts @@ -0,0 +1,307 @@ +/** + * 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. + * + * 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 { + AgentSessionHandleProvider, + AgentSessionProviderHandle, + AgentSessionProviderHandleNamespace +} from './agent-session-provider-handle' +import type { + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle +} 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 resume cursor is its 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 ? {} : { resumeCursor: leafUuid }) + } +} + +export function claudeProviderHandleLeafUuid(handle: AgentSessionProviderHandle): string | null { + return handle.resumeCursor ?? 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_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 { + 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 + * 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')) { + 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 resumeCursor = 'resumeCursor' in value ? value.resumeCursor : undefined + if ( + !isNamespacePart(transport) || + !isNamespacePart(agent) || + !isAgentSessionProviderHandleField(nativeId) + ) { + return false + } + const namespace = { transport, agent } + if (isAgentSessionProviderHandleInNamespace(namespace, CODEX_STRUCTURED_HANDLE_NAMESPACE)) { + return resumeCursor === undefined + } + if (isAgentSessionProviderHandleInNamespace(namespace, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)) { + return resumeCursor === undefined || isAgentSessionProviderHandleField(resumeCursor) + } + return ( + resumeCursor === undefined || + (typeof resumeCursor === 'string' && + resumeCursor.length > 0 && + resumeCursor.length <= MAX_RESUME_CURSOR_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.resumeCursor ?? 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.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', 'resumeCursor'] 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 + // 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.resumeCursor === undefined ? {} : { resumeCursor: value.resumeCursor }) + } +} + +// ─── 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: 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) +} + +/** + * 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 for this identity. Write-only: rows keep it for diagnosis, + * nothing reads it. */ +export function agentSessionJournalProviderHandle( + identity: Pick +): AgentSessionJournalProviderHandle { + const handle = identity.providerHandle + if (!handle) { + return { + kind: 'opaque', + agent: identity.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 7474c751cbe..d66c0356815 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, 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. + 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, resumeCursor?: string): AgentSessionProviderHandle => ({ + transport: 'acp', + agent: 'grok', + nativeId, + ...(resumeCursor === undefined ? {} : { resumeCursor }) + }) + + 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 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) + 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 ea789dcdfe4..3bf8fb69b06 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 `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 + * 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,31 @@ 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 = { + /** + * 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 + /** + * 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. */ +export type AgentSessionProviderHandleNamespace = Pick< + AgentSessionProviderHandle, + 'transport' | 'agent' +> export type AgentSessionProviderHandleOrigin = 'created' | 'adopted' | 'resumed' | 'forked' @@ -43,54 +87,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, resume cursor 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.resumeCursor === right.resumeCursor + ) } export function agentSessionProviderHandleChainHead( @@ -113,6 +122,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 +134,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 +189,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 +257,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 00000000000..22ef2b54a9a --- /dev/null +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -0,0 +1,318 @@ +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', resumeCursor: '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', + resumeCursor: '{"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 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)) + }) + + 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: 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', + resumeCursor: '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, 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', resumeCursor: '' }, + null + ]) { + 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('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] + ])('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', + resumeCursor: 'resume-token' + }) + expect(isPersistedAgentSessionRecord(stored)).toBe(false) + }) +}) + +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({ + agent: 'claude', + providerHandle: claudeProviderHandle('sess-1', 'leaf-1') + }) + ).toEqual({ kind: 'claude', sessionId: 'sess-1', leafUuid: 'leaf-1' }) + 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({ + agent: 'grok', + providerHandle: { transport: 'acp', agent: 'grok', nativeId: 's' } + }) + ).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 00000000000..ca6d242d24a --- /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 3e2270d368b..68a190fbbca 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 a9d563392f0..3bdef0ea1e8 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 @@ -335,8 +336,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 { @@ -349,7 +350,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)) && @@ -367,9 +367,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 54e44d7fa88..9b1dc2306e2 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -76,6 +76,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' @@ -142,7 +143,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-newer-chat-refusal.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts index 42f5856e35c..f7abbe134dc 100644 --- a/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts @@ -5,8 +5,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentSessionJournalIdentity + type AgentSessionJournalIdentity, + type AgentSessionJournalProviderHandle } from '../../../src/shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import type { AgentSessionRefusalReference } from '../../../src/shared/agent-session-wire-refusals' import { closeTestJournalHostDatabase, @@ -43,6 +45,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' } } @@ -218,14 +230,14 @@ describe('a chat a newer Orca saved, across versions', () => { const older = releaseExport< () => { open: (options: { - identity: AgentSessionJournalIdentity + identity: OlderJournalIdentity stateDirectory: string }) => Promise closeAll: () => Promise } >(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/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 3fc06f9d2dc..d7444e2c77e 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 { @@ -34,6 +36,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' } } @@ -164,7 +176,7 @@ type OlderJournal = { type OlderOpener = { open: (options: { - identity: AgentSessionJournalIdentity + identity: OlderJournalIdentity stateDirectory: string }) => Promise closeAll: () => Promise @@ -204,7 +216,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) @@ -277,7 +289,7 @@ test("this build keeps a newer build's row kind and refuses the load; a build be ) 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 1980ed95c55..d427eb5109a 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 b7734f2759d..0220a33e59a 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 4968af98a93..abf2f994004 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