Skip to content

feat: session.fork — branch a conversation, optionally onto another backend#146

Merged
saucam merged 2 commits into
mainfrom
feat/session-fork
Jul 10, 2026
Merged

feat: session.fork — branch a conversation, optionally onto another backend#146
saucam merged 2 commits into
mainfrom
feat/session-fork

Conversation

@saucam

@saucam saucam commented Jul 10, 2026

Copy link
Copy Markdown
Owner

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: canonicalHistory getter + primeFromFork(history, transcript). Seeds the accumulator, replays the parent's transcript into the fork's scrollback for UI visibility — deliberately without tripping restoreScrollback'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 #seedProviderFromHistory helper now shared with switchProvider (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 independent Session (providerId = requested ?? parent's), primes it, audits. Fail-closed: foreign/unknown parent → not_found, unknown providerIdinvalid_request, conductor → invalid_request.
  • Protocol SessionForkMsg — wire-additive (new ClientMessage, 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: the primeFromFork primitive (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-tenant not_found; unknown session not_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 — recalled FORKTEST-42. The conversation crossed.

bun run test (1275 pass), typecheck, lint green. Independent of #145 (rebased onto main).

🤖 Generated with Claude Code

…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.
Comment thread src/daemon/session.ts
Comment thread src/daemon/providers/canonical.ts
@github-actions

Copy link
Copy Markdown

🤖 Gemini code review

This PR implements the session.fork feature, allowing a conversation to be branched into an independent session, optionally onto a different backend provider. While the implementation is largely clean and robust, there are critical issues around the persistence of the forked scrollback across daemon restarts and potential side-effects from shallow-copying nested structures in the conversation history.

Findings: 🔴 1 · 🟠 0 · 🟡 1 · 🟢 0


Tokens spent · ⬆️ Input: 8,058 · ⬇️ Output: 628 · Σ Total: 18,675
Total may be higher due to thinking token counts.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.39130% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.07%. Comparing base (d88adf5) to head (7034d7f).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/daemon/session.ts 91.17% 3 Missing ⚠️
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     
Flag Coverage Δ
daemon 83.07% <97.39%> (+0.24%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
packages/protocol/src/schemas.ts 100.00% <100.00%> (ø)
packages/protocol/src/types.ts 100.00% <ø> (ø)
src/daemon/providers/canonical.ts 100.00% <100.00%> (ø)
src/daemon/session-manager.ts 68.43% <100.00%> (+2.94%) ⬆️
src/daemon/session.ts 95.34% <91.17%> (-0.10%) ⬇️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… (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.
@saucam saucam merged commit f253302 into main Jul 10, 2026
4 checks passed
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