Skip to content

refactor(native-chat): keep the provider resume handle opaque to shared code - #24991

Draft
brennanb2025 wants to merge 8 commits into
mainfrom
brennanb2025/acp-a1-opaque-handle
Draft

brennanb2025 wants to merge 8 commits into
mainfrom
brennanb2025/acp-a1-opaque-handle

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 161 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1005 $\color{#cf222e}{\Huge{\mathbf{−}}}$​423 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​582
Prod 44 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​687 $\color{#cf222e}{\Huge{\mathbf{−}}}$​229 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​458

ELI5

Every structured native chat keeps a bookmark that says which conversation inside the agent it continues (Claude calls it a session, Codex calls it a thread). Until now, Orca's shared code knew the exact layout of that bookmark for Claude and for Codex, so adding any new agent meant teaching a dozen shared files a third layout. This PR turns the bookmark into a generic id plus a sealed note that only the agent's own code opens. Nothing changes for anyone using the app today; it is groundwork so the next agents (starting with Grok over the Agent Client Protocol) plug in without touching shared code.

What Changed

The problem. The "provider handle" (the durable pointer from an Orca chat to the provider's own conversation) was a typed union in shared code: { provider: 'claude', sessionId, leafUuid } | { provider: 'codex', threadId }. Shared readers picked fields by provider: ownership lists, orchestration mail addressing, history results, the record validator, the lease and handle-chain checks, and the journal identity. Claude's leafUuid (which transcript entry a resume continues from, a branch cursor only Claude has) was interpreted in shared runtime code. A new transport would have had to add a union arm and a branch at every one of those sites.

User-facing change: none. Claude and Codex chats create, resume, fork, rewind and recover exactly as before, and everything stored on disk or sent over the wire for them is byte-for-byte the same.

The mechanism.

  • In-memory handle is provider-neutral (src/shared/agent-session-provider-handle.ts):
    { transport, agent, nativeId, resumeCursor? }.
    • transport is an open vocabulary for the protocol whose id space nativeId lives in ('claude-sdk', 'codex-app-server', later 'acp').
    • agent is the Orca agent whose binary resumes the conversation. One transport serves many agents, so the transport alone does not scope an id.
    • nativeId is the provider's own conversation id (Claude's session id, Codex's thread id). Shared code reads only this.
    • resumeCursor is resume state only that provider's adapter reads (where a resume continues from), never identity. For Claude it is the leaf uuid; Codex has none.
    • transport records the id space the native id was created in. If an agent later changes transport, its old chats must become not resumable on that build, never unreadable records.
    • A stored handle has a fixed field set: a later build adds new per-link data on the chain link (which every build keeps), never on the handle, because a rewrite drops unknown handle fields.
  • Shared readers use nativeId only: ownership listing, mail-address lookup, the history result's provider session id, the rewind target check, journal recovery, and adopted-transcript import.
  • Lease and handle-transition checks compare transport + agent (agentSessionProviderHandleBelongsTo) and never read the handle's data. The chain rules (created / adopted / resumed / forked, unsaved-creation supersession, fence ordering) stay on the link and are unchanged.
  • Claude's leaf moved behind the Claude adapter. reviseAgentSessionClaudeResumePoint became the generic reviseAgentSessionProviderResumePoint, which takes a whole replacement handle and only refuses one that names another conversation. The Claude runtime adapter builds that handle from its leaf. Claude launch resolution and the Claude history window read the leaf through claudeProviderHandleLeafUuid.
  • One encoding module owns every stored and wire form (src/shared/agent-session-provider-handle-encoding.ts):
    • Claude and Codex handles keep being written in today's typed shapes, so an older Orca build sharing the database still reads them.
    • A read-time normalizer decodes both the typed shapes and the neutral shape into the in-memory type. Decoding is strict: each handle has exactly one stored form. Claude or Codex written in the neutral shape is refused, and so is any object that carries both forms: a typed Claude/Codex handle that also has any of the neutral shape's transport, agent, nativeId or resumeCursor fields names two identities, so it is refused rather than read as Claude/Codex (which would silently drop the other identity on the next write). The whole record is then set aside as unreadable and kept byte for byte. Unrelated extra fields on a typed handle are still tolerated on read (and dropped on the next write).
    • Other transports are stored in the neutral shape. Older builds check provider === 'claude' / 'codex' and refuse it, so it is set aside as unreadable (never rewritten, every mutation refused) instead of being read as Codex. The "unknown providers must not impersonate Codex" guard keeps its intent.
    • The key and root strings, which other persisted fields store, are derived from the stored form and stay byte-identical for Claude and Codex (claude:["sid","leaf"], claude:"sid", codex:"tid").
  • Record decode/encode (src/shared/agent-session-record-stored-form.ts) wraps the existing lease normalization. Rows are decoded on load and encoded on every write: the store's row diff and the one-time records-file import. normalizeLegacyHandoffRecord is replaced by decodePersistedAgentSessionRecord.
  • The two handle types are now one concept. The journal's own AgentSessionProviderHandle ({ kind: 'claude' | 'codex' | 'opaque', … }) is renamed AgentSessionJournalProviderHandle and documented as what it always was in practice: the encoding journal rows record and the attach wire carries. Every journal row builds it the same way, from the journal identity, through one function (agentSessionJournalProviderHandle(identity)): the identity's handle in row form, or pending before one is proved. Tests build row handles through that same function instead of writing the in-memory handle into a row. The journal identity handed to adapters now carries the neutral handle, or null before the provider has proved one (today this is the { kind: 'opaque', value: 'pending' } placeholder, which journal rows still record byte-for-byte). The reserved opaque arm gets its intended use: a non-Claude/Codex handle is recorded in journal rows as { kind: 'opaque', agent, value: nativeId }.

Census of every place a handle is persisted or sent (checked before changing anything):

Where What it holds After this PR
agent_session_records.record_json (chat journal database) providerHandleChain[].handle Claude/Codex: unchanged typed shape. Other transports: neutral shape
Legacy agent-sessions.json records file (read once by the version-4 import) same read through the same decoder, imported rows written in the typed shape
Link fields forkedFromKey, supersedesKey handle key strings byte-identical for Claude/Codex
Restart resume marker providerHandleRoot (persisted across quit/relaunch) handle root string byte-identical for Claude/Codex
Journal epoch and submission rows (providerHandle) { kind } handle; write-only, readers only check it is an object unchanged for Claude/Codex/pending; other transports use the reserved opaque arm
agentSession.attach / ensure params (providerHandle, strict zod claude/codex), host-internal adopt.providerHandle { kind } handle; the attach fingerprint covers it as sent unchanged (kept in wire form, converted at the boundary)
Operation-ledger fingerprints computed from the wire-form params unchanged
Structured history result providerSession.id, orchestration mail lookup by provider id the provider's id string same value, now read from nativeId
Renderer and mobile no handle crosses to them; they import only isAgentSessionHandleProvider / AgentSessionHandleProvider unchanged

Nothing an older client or host decodes changes shape, so no capability gate is needed.

Overlap with #24862. Both PRs touch src/shared/agent-session-record.ts, structured-claude-runtime-adapter.ts, codex-structured-session-acquire.ts and seven test files or fixtures. A trial merge (git merge-tree) of this branch with #24862's head shows no conflict in any of them; the only conflicts in that trial come from main changes #24862 has not merged yet (i18n catalogs, and a test file it deletes that main has since edited). Its only new handle literal is an attach-wire one, which this PR keeps valid. Whichever lands second should re-run its tests.

Why

The common pattern keeps the provider's resume handle generic in shared code: a native id that shared code reads, plus provider-owned data it never parses. Orca's typed union was the outlier, and it is why every new agent touched shared types.

Alternatives considered:

  • Add one union arm per transport. Rejected: every new agent keeps editing shared types and readers, which is the problem.
  • Keep the typed shapes in memory and add a third, generic arm, with accessor functions. Smaller diff and no persistence boundary. Rejected because the typed fields stay in memory, so nothing stops the next reader from narrowing on provider === 'codex' again. With the neutral type, the provider fields do not exist outside the encoding module.
  • Write Claude and Codex in the neutral shape too. Rejected: an older build sharing the database would set every existing chat aside as unreadable after a downgrade.
  • Make Claude's leaf a generic "conversation head" field. Rejected for now: only Claude has a branch cursor, and an opaque field keeps shared code from giving it meaning. Adding a typed field later is cheap if a second provider needs one.

Differences from the common pattern

  • Claude and Codex are still stored and sent in their typed shapes (intended). Older Orca builds share the database and the wire. The common pattern had no earlier typed shape to stay compatible with. The typed shapes are confined to the encoding module, and decoding is strict.
  • Claude's key includes its leaf; a generic handle's key is its root (intended). Fork seeds, superseded creations and resume offers already store the Claude key strings, so they cannot change. For other transports, provider data is resume state, not identity.
  • The handle names the agent as well as the transport (intended). One protocol (the Agent Client Protocol) serves many agents whose ids share no namespace.
  • No strength or confidence field on the handle (intended). Orca's chain origin (created / adopted / resumed / forked) and unsaved-creation supersession already record how much the provider proved.
  • The attach wire and journal rows still use the typed { kind } shape for Claude and Codex (temporary). The wire follow-up (feat(native-chat): open structured chat's wire and stored records to registered agents, behind a negotiated capability #25159) widens what clients and hosts exchange behind a new advertised capability. Journal rows are write-only and need no change.
  • record.provider is still 'claude' | 'codex', and the account-home variable is still a closed pair (temporary). refactor(native-chat): structured agents declare their capabilities instead of shared code naming Claude and Codex #25076 gives each of them one owner; feat(native-chat): open structured chat's wire and stored records to registered agents, behind a negotiated capability #25159 opens the stored agent list behind a negotiated capability and replaces the one function that derives a record's handle namespace from record.provider (agentSessionProviderHandleNamespace). The account-home variable opens with the first protocol agent's launch description.

Linked Issue

None (maintainer foundation PR for structured native chat beyond Claude/Codex).

Visual Proof

N/A: no UI or behavior change. The handle is internal state, and stored and wire bytes are unchanged for Claude and Codex.

CI status (October 5)

Merged current main (clean). On head 4178e5a0385 every CI job passes, including typecheck, cross-version wire compatibility and packaging, except unit shards 1/5 and 4/5. Those two fail on main's own locale tests, which #25418 broke by adding translations the tests expect to be missing (NativeChatSupportedAgents.test.tsx, source-control-discard-localization.test.ts). This PR touches neither those tests nor any locale catalog. The aggregate verify job fails only because of those shards.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New tests (src/shared/agent-session-record-stored-form.test.ts, src/shared/agent-session-provider-handle.test.ts):

  • Claude and Codex record rows exactly as today's code writes them (including a fork link's forkedFromKey and a superseding creation's supersedesKey) validate, decode to the neutral handle, and encode back byte for byte.
  • Claude/Codex handles encode to today's typed shapes, which still pass the rules older builds apply.
  • Key and root strings for Claude/Codex are pinned to today's exact strings.
  • A neutral handle for another transport (acp/grok with provider data) round-trips, is refused by older builds' rules (it does not read as Codex), and has no attach-wire form.
  • Strict decoding refuses Claude/Codex in neutral shape, mixed shapes (including a typed Claude or Codex handle carrying a conflicting neutral identity), an empty leaf, a padded thread id, and a malformed transport. A typed handle with an unrelated extra field still decodes.
  • A whole Claude or Codex record whose handle carries a conflicting neutral identity is unreadable; in the store it is set aside, and a later write by another chat leaves the stored row unchanged.
  • The attach wire form maps both ways, and journal rows record the same { kind } objects as before (including pending).
  • A chain of an unknown transport resumes and forks by nativeId alone. A same-fence resume that only moved the resume cursor is recorded, not elided. A chain never accepts a link from another transport or agent with the same id.
  • The leaf revision refuses a handle of another namespace with the same id.

Run: vitest run --config config/vitest.config.ts on every test file that changed or imports a changed module (480 files, 4780 tests): all pass except claude-agent-sdk-contract-pins.test.ts. That test reads the installed SDK's package.json from the shared node_modules of my checkout (0.3.251 against the pinned 0.3.284). This PR touches neither, so it is a stale install on my machine, not this change. Also run: the changed-code quality gate (check-changed-code-quality.mjs, passes), oxlint and oxfmt on every changed file (clean).

What I verified / didn't

  • Verified: the byte-for-byte round trip of today's row shapes, the census above (by reading every reader and writer of the handle and of providerHandleChain), and that older builds set an unreadable record aside without rewriting it (by reading agent-session-record-rows.ts; I did not run an older build).
  • Verified: no textual conflict with fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held #24862 or current main (git merge-tree).
  • Verified: tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts passes. It opens the same journal with an older build's code, which now gets its identity in the typed form it expects. The other changed tests/e2e unit tests pass too.
  • Typecheck: CI's typecheck job passes on the current head. Locally only the shared, out-of-date dependency install reports missing modules unrelated to this PR. Not run: the real-CLI Claude suites. No manual app run: nothing user-visible changed.
  • Passing tests show nothing regressed; they do not prove the shape is the right one. The design argument is above.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: decoding stays strict and bounded (id ≤ 512 chars, provider data ≤ 4096, transport/agent are short slugs). A client still cannot choose a handle shape beyond the existing strict attach schema.
  • Cross-platform / SSH / WSL: no path or process behavior changed. Handles are host-owned data on the execution host.
  • Mobile: nothing on the wire changed; mobile imports only the unchanged provider guard.
  • Backwards compatibility: covered by the census and round-trip tests above.
  • Performance: one extra object map per record load and write. Records are small and bounded (≤ 256 links).
  • Size: about 200 files, mostly mechanical test-fixture updates ({ provider: 'codex', threadId } → codexProviderHandle(...)). The substantive change is in the two new shared modules and about 20 non-test files.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…ed 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.
…l 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.
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.
…n 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.
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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary (head 4178e5a0385)

The problem. Orca's shared chat code knew the exact layout of the bookmark that links a structured chat to the agent's own conversation: Claude's session and transcript position, Codex's thread. Every new agent (Grok first) would have meant teaching a dozen shared files a third layout.

User-facing change: none. Claude and Codex chats create, resume, fork, rewind and recover as before, and everything stored on disk or sent between a client and a host is byte-for-byte the same for them.

What the review fixed

  • An older version of this branch accepted a stored bookmark written in both the old and the new layout, and the next save erased one of the two. It is now refused, and the whole record is kept untouched and set aside as unreadable.
  • The bookmark's adapter-only field is renamed from providerData to resumeCursor, before any stored row can contain it. It names what shared code treats it as: where a resume continues from, never identity.
  • The stored-format rule is now written down and pinned by a test: a stored bookmark has a fixed set of fields, so later builds put new data on the chain link, which every build keeps. The meaning of transport is documented too: it records where the conversation id was created, so an agent that later changes protocol must make old chats not resumable, never unreadable. Enforcing that belongs to feat(native-chat): open structured chat's wire and stored records to registered agents, behind a negotiated capability #25159 and is tracked there.
  • A main merge pushed one test file over the line limit; fixed.

Deferred (minor, no behavior impact today)

  • Journal recovery still turns "no bookmark yet" back into the placeholder id pending for a transcript lookup (same as before this PR).
  • The journal's generic bookmark row form omits the transport. Those rows are write-only today.
  • The wire decoder reads any non-Claude kind as Codex. The request schema already refuses other kinds.
  • Each chain is decoded twice on load (at most 256 links).
  • The Claude and Codex constructors live in the encoding module.

Verified

  • Two independent round-1 reviews: compatibility and persistence, and an architecture challenge that designed its own solution first and recommended keeping this one, without a split. A final readiness review found no P0–P2 issues.
  • Byte-for-byte round trips of today's Claude and Codex rows, keys and wire forms. Mixed-version rules: an older build sets a new-layout record aside without rewriting it.
  • Targeted suites: 14 files, 214 tests, pass. CI on this head: typecheck, cross-version wire compatibility and packaging pass. Unit shards 1/5 and 4/5 fail on main's locale tests broken by Add translations for terminal shell settings #25418, which this PR does not touch.

Not verified

  • No app or real agent run. This PR has no user-visible change, so there was no live QA.
  • The real-CLI Claude suites were not run.
  • No actual older Orca build was run against an upgraded database. The older-build journal path is covered by the cross-version test.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant