Skip to content

feat: forks, switches and rewinds continue the conversation natively on Claude and Codex - #388

Merged
saucam merged 7 commits into
mainfrom
feat/native-continuation-387
Oct 17, 2026
Merged

saucam merged 7 commits into
mainfrom
feat/native-continuation-387

Conversation

@saucam

@saucam saucam commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #387.

What changes

When you fork a session, switch its backend, or rewind it, the new backend has to be given the conversation so far. Until now Claude, Codex and pi got it as a text transcript in the first prompt:

  • each tool's input and output cut to 2,000 characters;
  • the oldest turns dropped to fit;
  • the model's reasoning gone.

That happened even when the fork stayed on the same backend.

Now the conversation goes over in the backend's own form wherever the backend allows it:

Going to Same backend (fork, rewind) From another backend (fork, switch)
claude Claude forks its own session at that turn: nothing converted or cut, reasoning kept codeoid writes the conversation as a Claude session with real tool_use/tool_result blocks, and checks it with the SDK's own reader before using it
codex Codex forks its own thread through that turn codeoid injects the conversation into a new thread as Codex's own history items
openai, gemini, llamacpp full history natively (unchanged) full history natively (unchanged)
pi, gemini-cli, qwen text transcript (unchanged) text transcript (unchanged)

The transcript stays as the fallback. It is used for any native path that fails. On a fork or switch the session says which way it went: ↪ … continues its own conversation, ↪ … was given … as its own history, or ↪ … carried as a text transcript. session.nativeContinuation (also CODEOID_NATIVE_CONTINUATION and the Settings UI) turns this off.

Two guards on what crosses natively:

  • Redacted output. If a tool_result hook redacted output, a native fork would copy the backend's unredacted log. The redaction is tracked per backing session, durably, and inherited by forks.
  • Imported turns. Turns rebuilt from an imported transcript are never built into native history, so they're never replayed in the assistant's voice. A native fork from a real turn after them is still used, because it copies the backend's own record.

A native fork is skipped when the source was already near the target's window, or from a turn that ended in an error. So forking stays the way out of an overflowed session (#381).

Also fixed along the way:

  • Codex lost its conversation on every daemon restart. It never saved the thread id it created, so after a restart it resumed a stale id and silently started an empty thread.
  • Claude's "No conversation found" recovery started a blank session and kept only the latest prompt. It now hands the new session the conversation.
  • A restart between a backend switch and the first prompt now re-seeds the new backend instead of resuming nothing.

How the spike shaped the design

The spike is in the issue; each point was verified against the real CLIs.

  • Synthesized history is accepted. Claude resumes a session from any project dir, and both the Anthropic API and Codex accept history that names tools they don't have.
  • Codex injection is durable. Injected Codex threads survive an app-server restart.
  • The SDK's sessionStore isn't usable. A resume through it is not durable: the CLI resumes from a temp copy, and a plain resume later fails. So codeoid writes a normal Claude session file and verifies it with getSessionMessages. Once verified, Claude owns it like any other session.
Code tour
  • Provider contract (providers/interface.ts):
    • AgentProvider.continueNatively(req) takes history, forkFrom?, maxChars, workdir and allowHistory?, and returns { via: "fork" | "history", backingId?, truncation? }.
    • NormalizedTurnResult.nativeRef is { backingId, ref, dir?, redacted? }.
  • Canonical turns (providers/canonical.ts): assistant turns carry native (where they end in the backend's own conversation) and restored. Tool calls carry redacted. Shared helpers: fitTurnsToBudget and canonicalTurnChars.
  • Claude (providers/claude/index.ts, providers/claude/native-history.ts):
    • Turn anchor: translateSDKMessage tracks the last main-chain entry uuid per turn (not subagent, not replay). It is stamped on successful results only, together with the query's cwd.
    • continueNatively:
      • Fork path: forkSession (pinned to the source's project dir), then read the fork back, then adopt it.
      • Fallback: toClaudeSessionEntries, then writeClaudeSession (0600 file, wx, 0700 dir), then getSessionMessages must read it back exactly, then adopt it.
      • Project-dir naming matches the SDK byte for byte, long paths included.
  • Codex (providers/codex/index.ts, providers/codex/native-history.ts):
    • The pending native continuation is applied when the thread is created on the first turn (it takes that turn's instructions). It runs thread/fork with lastTurnId and excludeTurns, or thread/start plus thread/inject_items.
    • On failure the turn emits a custom message and falls back to the transcript.
    • Turn anchors are stamped on completed turns only. The thread id is persisted via #adoptThread.
  • Session (session.ts): #seedProviderFromHistory tries #continueNatively before the context strategy. It decides forkFrom (same backend, valid ref, no redaction, fits the window) and allowHistory (no restored turns). Supporting changes:
    • #identityOnNextSend, so a native continuation still registers the ZeroID agent on its first send;
    • #nativeBackingReady, so the backend isn't treated as fresh when it already holds the conversation;
    • #redactedBackings, persisted in session meta, restored on resume and inherited by forks;
    • the #announceContinuation notice.
  • Recovery: onRecoveryNeeded reseeds the new backing from the history before the prompt.
  • Config: session.nativeContinuation, CODEOID_NATIVE_CONTINUATION, and a Settings manifest entry.

Verification

Tests:

  • 33 new tests in native-continuation.test.ts:
    • the converters (chain integrity, tool pairing, naming, budgets);
    • turn anchors;
    • Claude continueNatively (fork, fork read-back, verify-or-remove, allowHistory, pinned dir);
    • Codex over the fake app-server (fork, inject, fallback, thread-id persistence);
    • session selection (same and cross backend, fallbacks, config off, switch freshness, rewind, identity registration, the context-fit guard, recovery reseed, redaction durability across restart and fork, restored turns).
  • Typecheck and lint are clean.
  • Full suite: 3284 tests: 3265 pass, 19 skipped, 0 failures. One run along the way had a timing-sensitive failure in an untouched /mcp/control test; it passed 3/3 on its own and in the next full run.
  • 2 new tests in provider-claude.test.ts: backing recovery can't loop, and it re-arms after a successful turn.
  • Mutation-checked: 21 guards. 20 are caught by a test. The 21st, the per-turn native.redacted check, survives because the backing-level redaction set catches the same cases, so it is defence in depth. The 21 cover:
    • the fork point being same-backend only;
    • the config switch;
    • the native backing not being marked fresh;
    • read-back verification, for both the history path and forks;
    • the turn anchor (no subagents or replays, success only);
    • the Codex transcript fallback;
    • Codex thread-id persistence;
    • tool_use/tool_result pairing;
    • identity registration;
    • the context fit;
    • the recovery reseed;
    • the redaction guards (stamping, restore-time learning);
    • restored turns;
    • the SDK's long-path naming;
    • the recovery latch: it doesn't loop, and it re-arms after a successful turn.

Run live against real Claude (Haiku) and real Codex (gpt-5.6-luna), through SessionManager.handle with the real provider registry, in a scratch store:

Scenario Result
Claude fork at turn 1 ✅ knew the secret read in turn 1, not the word said in turn 2; "continues its own conversation"
...after a simulated daemon restart ✅ resumed natively, still knew it
Claude rewind of turn 2 ✅ only turn 1 remained
Claude → Codex switch ✅ Codex recalled a token Claude had read with a tool
Codex → Claude switch ✅ Claude recalled Codex's exact cat command and output
Codex fork at turn 1 ✅ knew turn 1, not turn 2

The Claude fork, the restart, and Codex → Claude were re-run after the round-1 fixes and still pass. Session files the runs left in ~/.claude and ~/.codex were cleaned up.

What did not run:

  • A real daemon over WebSocket, or the web/TUI clients. The live run drove SessionManager.handle, the same entry point the WebSocket server calls.
  • A ZeroID-mode daemon. Identity registration is covered by a unit test with a fake identity manager.
  • pi, gemini-cli and qwen. They are unchanged (transcript), covered by the existing suites.
  • Very large real sessions. The fork read-back parses the whole session file on the daemon thread (the same class of cost as fix: memory keeps every byte and stops freezing the daemon #379); this is noted as a follow-up.
  • Oracle: n/a. codeoid is not in enabled_repos.

Audit rounds:

  • Round 1.
    • Correctness:
      • High: a native fork didn't register its agent identity.
      • Mediums: forking an overflowed session; an unverified fork; recovery losing history; a stale anchor.
      • Lows.
    • Security:
      • Medium: native fork bypassed tool_result redaction.
      • Lows: restored turns in the assistant's voice, unvalidated refs, file hygiene, error text sent to clients.
    • All fixed in 96c7350 and cf6b9d9.
  • Round 2.
    • Security: "OK to merge", with one Low (the redaction flag could be lost after a partial load or compaction).
    • Correctness: "no Critical or High findings, one Medium" (the same redaction durability issue), plus Lows on the recovery seed, a recovery loop, restored-turn forks being too conservative, and unknown usage.
    • All fixed in 29f3431.
  • Round 3 (combined verification): "not clean", one Medium. The round-2 recovery-loop guard was dead, because the session's reset ran before it. Lows: the redaction flag leaking into the next turn, and a meta write after destroy. All fixed in 5e0fe0c.
  • Round 4 (verification): "The round is clean. I found no Critical, High or Medium issues, the three fixes are correct". Its optional Lows were folded into d0e810c, except one, which is kept as documented below.
  • Final audit (whole PR, every claim checked against the code): "ready for human review once the two doc fixes (1 and 2) are in", with no code issues found. Both doc fixes and its 13 polish items are in 6921877.

Known limitation: if a recovery adopts a native backing that also can't be resumed, the session reports the error and stays failed until /rotate or a restart. It no longer loops. The suggested alternative, silently starting a blank session, would lose the conversation, so I didn't take it.

Follow-ups (not in this PR)

🤖 Generated with Claude Code

saucam and others added 6 commits October 17, 2026 01:28
A fork, backend switch or rewind used to hand Claude, Codex and pi a text
transcript in the first prompt (tool I/O cut to 2,000 chars, reasoning
dropped), even when nothing changed backend. Now the session asks the
backend to continue natively first (#387):

- same backend: the backend forks its own conversation at the turn
  (Claude Agent SDK forkSession; Codex thread/fork), from a native point
  every turn now records (NormalizedTurnResult.nativeRef, stamped on the
  canonical turn) — nothing converted or cut, reasoning kept;
- another backend: native history built from the canonical turns — a
  Claude session file with real tool_use/tool_result blocks, verified with
  the SDK's own reader before use, or Codex items via thread/inject_items.

The transcript is the fallback (pi, the Gemini CLI, any failure), the
session says which way it went, and session.nativeContinuation /
CODEOID_NATIVE_CONTINUATION turns it off.

Also fixes Codex never saving its thread id, which silently emptied every
Codex conversation on a daemon restart, and marks a switched-to backend
fresh until its first prompt so a restart re-seeds it.

Closes #387

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…restored turns quoted, pin and validate fork points

Security review: a native fork copies the backend's own log, which still
holds what a tool_result hook redacted from codeoid's record, so a fork is
not used once any carried tool call was redacted (codeoid's record is
rebuilt natively instead). Turns rebuilt from a displayed transcript (an
import) stay a quoted transcript rather than native history in the
assistant's voice. Native refs are shape-checked, and Claude's fork lookup
is pinned to the source session's project dir. The session file is written
with wx into an owner-only dir and its temp file removed on failure. The
Codex fallback notice no longer forwards backend error text to clients.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…into an overflow, recovery reseeds

Correctness review:
- A fork that adopted a native backing counted as having queried, so its
  ZeroID agent identity was never registered and model calls fell back to
  the shared badge. Identity now registers on the first send after any
  native continuation.
- No native fork from an errored, interrupted or budget-stopped turn (no
  nativeRef is stamped), nor when the source's last context was within 85%
  of the target's window: a fork stays the way out of an overflowed session
  (#381), with budgeted native history instead.
- A Claude fork is read back before it is adopted, and Claude's
  'No conversation found' recovery now reseeds the new session from the
  conversation instead of silently keeping only the latest prompt.
- Stale turn anchors are cleared on a session reset or loop rebuild; a
  rewind to the start clears the native flag; long workdir paths are named
  exactly as the SDK names them; Codex anchors only completed turns, forks
  with excludeTurns, and can't adopt a thread after a teardown.
- Settings UI exposes session.nativeContinuation; docs cover qwen and how
  this sits with CODEOID_CONTEXT_STRATEGY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…urably; imported sessions may still fork

- A tool_result redaction now marks the BACKING, not just the call: every
  later turn of it carries native.redacted, the set is written to the
  session meta the moment a redaction happens (so a crash before the turn
  ends can't forget it), restored on resume, and inherited by forks. A
  partial restart load, log compaction or a squeezed giant turn can no
  longer hide the redaction from the fork guard.
- Turns rebuilt from a displayed transcript block only BUILDING native
  history (allowHistory: false); a fork of the backend's own record is still
  allowed.
- With no usage on record, whether a fork fits is estimated from the
  history instead of assumed.
- Claude recovery: a transcript seed it applies records the prompt it rides
  (so an overflow retries there), and adopting a backing doesn't re-arm
  recovery.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…tion flag can't leak into the next turn

- Claude's 'No conversation found' recovery latch no longer clears when the
  backing is reset (recovery resets it itself, then may adopt a resumable
  native backing): it clears only after a turn on the new backing succeeds.
  If that backing is missing too, the turn now fails instead of recovering
  in a loop. (The round-2 guard in #adoptBacking was dead: the reset ran
  before it.)
- The per-turn redaction flag is cleared when a new turn starts, so a
  redaction in a turn that never reached turn_done can't mark the next
  turn's backing.
- A late tool result can't rewrite a destroyed session's meta.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…keep the zero-turn docs on their tests

Round-4 audit: clean. Folds its two cheap lows.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@saucam

saucam commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green on d0e810c (daemon-check, web-check, Socket Security: Project Report, Socket Security: Pull Request Alerts all SUCCESS). No auto-fixes were needed, and the branch is up to date with main. Ready for human review.

…the code

providers-pi.md no longer says Claude can't take native history; FEATURES
states the imported-turns rule as it is (never built into native history,
a later real turn may still fork) and only claims a notice on fork/switch;
COMPARISON and CHANGELOG name every transcript backend and the recovery
fix; #seedProviderFromHistory and the Codex header describe the native
path. On a switch the continuation notice now follows the switch line,
and its transcript wording no longer says the backend can't (the feature
may be off). Codex's thread config is one helper; small type and naming
tidy-ups.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@saucam
saucam merged commit 6cdb01f into main Oct 17, 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.

feat: forks and backend switches continue the conversation natively on Claude and Codex

2 participants