feat: session.fork — branch a conversation, optionally onto another backend#146
Conversation
…ackend
Fork a session into a new independent one seeded with a COPY of the
parent's canonical history + scrollback, so the branch continues the
same conversation from the same point while the parent is untouched.
Optionally forks onto a DIFFERENT backend in one call
(session.fork {providerId}) — 'branch this claude conversation and
continue it on codex'. The purest expression of the meta-harness bet:
one history, branched across backends.
- CanonicalHistoryAccumulator.seed(turns): replace history with a copy
(fork priming).
- Session: canonicalHistory getter + primeFromFork(history, transcript)
— seeds the accumulator, replays the parent transcript into scrollback
for UI visibility (WITHOUT flipping hasQueried: the fork's backend is
fresh and runs its first turn as a create, not a resume), and offers
the history to the provider via the extracted #seedProviderFromHistory
helper (shared with switchProvider — stateless backends replay it every
turn, warm backends get a rendered transcript). Fresh id + fresh
backing id, so the fork can never resume the parent's native state.
- SessionManager #fork: snapshot parent history + transcript, build an
independent Session (providerId = requested ?? parent's), prime, audit.
Fail-closed: foreign/unknown parent → not_found, unknown providerId →
invalid_request, conductor → invalid_request.
- Protocol SessionForkMsg (wire-additive; schema + exhaustiveness sample).
Tests: primeFromFork primitive (history copied not shared, warm seed,
no-resume), and the manager handler F1-F5 (history carried, cross-provider
fork with the fork's provider seeded, unknown provider, cross-tenant
not_found, unknown session). Verified LIVE end-to-end: told claude a
codeword, forked onto codex, codex recalled it.
🤖 Gemini code reviewThis PR implements the Findings: 🔴 1 · 🟠 0 · 🟡 1 · 🟢 0 Tokens spent · ⬆️ Input: 8,058 · ⬇️ Output: 628 · Σ Total: 18,675 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #146 +/- ##
==========================================
+ Coverage 82.82% 83.07% +0.24%
==========================================
Files 104 104
Lines 17734 17823 +89
==========================================
+ Hits 14688 14806 +118
+ Misses 3046 3017 -29
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
… (Gemini review) - Critical: primeFromFork replayed the parent's transcript rows into the fork's scrollback still stamped with the PARENT's sessionId and never wrote them to the fork's own transcript — so clients saw a foreign id and the scrollback vanished on the next daemon restart. Restamp each row with the fork's id and persist it, making the fork a genuinely independent session with durable history. F1 now asserts the fork's transcript carries the restamped rows. - Medium: CanonicalHistoryAccumulator.seed used a shallow spread, sharing nested toolCalls/input objects between fork and parent. structuredClone instead — a mutation on either side can't leak.
What
Fork a session into a new, independent one that starts with a copy of the parent's conversation — and optionally continue the branch on a different backend in the same call.
session.fork {sessionId, providerId: "codex"}= "branch this claude conversation and continue it on codex." The parent is untouched; both evolve independently. This is the purest expression of the meta-harness bet: one history, branched across backends to compare or diverge.How
CanonicalHistoryAccumulator.seed(turns)— replace history with a copy (fork priming; the fork never shares turn objects with the parent).Session:canonicalHistorygetter +primeFromFork(history, transcript). Seeds the accumulator, replays the parent's transcript into the fork's scrollback for UI visibility — deliberately without trippingrestoreScrollback's "prior scrollback ⇒setHasQueried(true)", because a fork's backend is brand new and must run its first turn as a create, not a resume — and offers the history to the provider through an extracted#seedProviderFromHistoryhelper now shared withswitchProvider(stateless backends replay history every turn; warm backends get a rendered transcript). Fresh id + fresh backing id, so the fork can never resume the parent's native state.SessionManager#fork: snapshots the parent's history + transcript, builds an independentSession(providerId = requested ?? parent's), primes it, audits. Fail-closed: foreign/unknown parent →not_found, unknownproviderId→invalid_request, conductor →invalid_request.SessionForkMsg— wire-additive (newClientMessage, no version bump); schema + exhaustiveness sample.Composes with everything shipped: fork alone, fork + later
set_provider, or fork-with-provider in one shot.Tests
session-fork.test.ts: theprimeFromForkprimitive (history copied not shared, warm provider seeded, no-resume invariant) and the manager handler F1–F5 (history carried +(fork)name + new id; cross-provider fork with the fork's provider actually seeded; unknown provider rejected; cross-tenantnot_found; unknown sessionnot_found).Verified live end-to-end on real subscriptions: told a claude session the codeword
FORKTEST-42, forked it onto codex, and codex — a different backend — recalledFORKTEST-42. The conversation crossed.bun run test(1275 pass),typecheck,lintgreen. Independent of #145 (rebased onto main).🤖 Generated with Claude Code